use semver_analyzer_core::types::sd::{
ChildRelationship, CompositionChangeType, CompositionTree, ConformanceCheck,
ConformanceCheckType, SdPipelineResult, SourceLevelCategory, SourceLevelChange,
};
use semver_analyzer_core::{AnalysisReport, ApiChangeType};
use semver_analyzer_konveyor_core::{
FixStrategyEntry, FrontendPatternFields, FrontendReferencedFields, KonveyorCondition,
KonveyorRule,
};
use crate::TypeScript;
use semver_analyzer_konveyor_core::resolve_npm_package;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
pub fn generate_sd_rules(
report: &AnalysisReport<TypeScript>,
sd: &SdPipelineResult,
pkg_cache: &HashMap<String, String>,
) -> Vec<KonveyorRule> {
let mut rules = Vec::new();
let component_packages = build_component_package_map(sd, pkg_cache);
rules.extend(generate_composition_change_rules(sd, &component_packages));
rules.extend(generate_conformance_rules(
&sd.composition_trees,
&sd.conformance_checks,
&component_packages,
));
rules.extend(generate_context_rules(
&sd.source_level_changes,
&component_packages,
));
rules.extend(generate_prop_child_migration_rules(
report,
sd,
&component_packages,
));
rules.extend(generate_cross_family_child_to_prop_rules(
report,
sd,
&component_packages,
));
rules.extend(generate_deprecated_migration_rules(sd, &component_packages));
rules.extend(generate_prop_value_conformance_rules(
report,
sd,
&component_packages,
));
rules.extend(generate_required_prop_added_rules(sd, &component_packages));
rules.extend(generate_test_impact_rules(
&sd.source_level_changes,
&component_packages,
));
rules.extend(generate_prop_attribute_override_rules(
&sd.source_level_changes,
sd,
&component_packages,
));
rules.extend(generate_css_class_removal_rules(&sd.removed_css_blocks));
rules
}
fn build_component_package_map(
sd: &SdPipelineResult,
pkg_cache: &HashMap<String, String>,
) -> HashMap<String, String> {
if !sd.component_packages.is_empty() {
return sd.component_packages.clone();
}
let mut map = HashMap::new();
for (name, profile) in &sd.new_profiles {
if let Some(pkg) = resolve_npm_package(&profile.file, pkg_cache) {
map.insert(name.clone(), pkg);
}
}
for (name, profile) in &sd.old_profiles {
if !map.contains_key(name) {
if let Some(pkg) = resolve_npm_package(&profile.file, pkg_cache) {
map.insert(name.clone(), pkg);
}
}
}
map
}
fn pkg_for(component: &str, map: &HashMap<String, String>) -> String {
map.get(component)
.cloned()
.unwrap_or_else(|| "@patternfly/react-core".to_string())
}
fn generate_composition_change_rules(
sd: &SdPipelineResult,
component_packages: &HashMap<String, String>,
) -> Vec<KonveyorRule> {
let mut rules = Vec::new();
let mut prop_passed_members: HashMap<String, Vec<String>> = HashMap::new();
for tree in &sd.composition_trees {
let root = &tree.root;
let children_in_edges: HashSet<&str> =
tree.edges.iter().map(|e| e.child.as_str()).collect();
let root_prop_types = sd.new_component_prop_types.get(root);
for member in &tree.family_members {
if member == root {
continue;
}
if children_in_edges.contains(member.as_str()) {
continue;
}
if let Some(prop_types) = root_prop_types {
let suffix = member.strip_prefix(root.as_str()).unwrap_or("");
if !suffix.is_empty() {
let suffix_lower = suffix.to_lowercase();
for (prop_name, prop_type) in prop_types {
if prop_name == "children" {
continue;
}
if !prop_type.contains("ReactNode") && !prop_type.contains("ComponentType")
{
continue;
}
let prop_lower = prop_name.to_lowercase();
if suffix_lower.starts_with(&prop_lower)
|| prop_lower.starts_with(&suffix_lower)
{
prop_passed_members
.entry(root.clone())
.or_default()
.push(format!("{} (via `{}` prop)", member, prop_name));
}
}
}
}
}
}
for change in &sd.composition_changes {
match &change.change_type {
CompositionChangeType::NewRequiredChild {
parent,
new_child,
wraps,
} => {
let rule_id = format!(
"sd-composition-{}-requires-{}",
sanitize(parent),
sanitize(new_child)
);
let mut message = format!(
"<{}> now requires <{}> as a child component.\n",
parent, new_child
);
if let Some(ref after) = change.after_pattern {
message.push_str(&format!("\nExpected pattern:\n{}\n", after));
}
if !wraps.is_empty() {
message.push_str(&format!("\n<{}> wraps: {}\n", new_child, wraps.join(", ")));
}
if let Some(prop_members) = prop_passed_members.get(parent) {
message.push_str(&format!(
"\nIMPORTANT: The following components are passed via props on <{}>, \
NOT as JSX children. Do not move them into the children:\n",
parent
));
for pm in prop_members {
message.push_str(&format!(" - {}\n", pm));
}
}
let pkg = pkg_for(parent, component_packages);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=composition".into(),
format!("package={}", pkg),
format!("family={}", change.family),
],
effort: 3,
category: "mandatory".into(),
description: change.description.clone(),
message,
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", parent),
location: "JSX_COMPONENT".into(),
component: None,
parent: None,
parent_from: None,
not_parent: None,
not_child: None,
value: None,
from: Some(pkg.clone()),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "CompositionChange".into(),
component: Some(parent.clone()),
replacement: Some(new_child.clone()),
..Default::default()
}),
});
}
CompositionChangeType::FamilyMemberAdded { member } => {
let pkg = pkg_for(member, component_packages);
let rule_id = format!(
"sd-composition-{}-new-member-{}",
sanitize(&change.family),
sanitize(member)
);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=composition".into(),
format!("package={}", pkg),
format!("family={}", change.family),
],
effort: 1,
category: "optional".into(),
description: change.description.clone(),
message: format!(
"<{}> is a new component in the {} family.\n\
Consider using it for better structure and semantics.",
member, change.family
),
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", change.family),
location: "JSX_COMPONENT".into(),
component: None,
parent: None,
parent_from: None,
not_parent: None,
not_child: None,
value: None,
from: Some(pkg.to_string()),
file_pattern: None,
},
},
fix_strategy: None,
});
}
CompositionChangeType::FamilyMemberRemoved { member } => {
let pkg = pkg_for(member, component_packages);
let rule_id = format!(
"sd-composition-{}-removed-member-{}",
sanitize(&change.family),
sanitize(member)
);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=composition".into(),
format!("package={}", pkg),
format!("family={}", change.family),
],
effort: 3,
category: "mandatory".into(),
description: change.description.clone(),
message: format!(
"<{}> has been removed from the {} family.\n\
Remove usages or replace with the recommended alternative.",
member, change.family
),
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", member),
location: "JSX_COMPONENT".into(),
component: None,
parent: None,
parent_from: None,
not_parent: None,
not_child: None,
value: None,
from: Some(pkg.to_string()),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "Manual".into(),
from: Some(member.clone()),
..Default::default()
}),
});
}
_ => {}
}
}
rules
}
fn generate_conformance_rules(
trees: &[CompositionTree],
conformance_checks: &[ConformanceCheck],
component_packages: &HashMap<String, String>,
) -> Vec<KonveyorRule> {
let mut rules = Vec::new();
for tree in trees {
let mut child_to_parents: HashMap<&str, Vec<&str>> = HashMap::new();
for edge in &tree.edges {
child_to_parents
.entry(edge.child.as_str())
.or_default()
.push(edge.parent.as_str());
}
for edge in &tree.edges {
if edge.relationship == ChildRelationship::Internal {
continue;
}
let pkg = pkg_for(&edge.child, component_packages);
if let Some(grandparents) = child_to_parents.get(edge.parent.as_str()) {
for grandparent in grandparents {
let rule_id = format!(
"sd-conformance-{}-not-in-{}-use-{}",
sanitize(&edge.child),
sanitize(grandparent),
sanitize(&edge.parent),
);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=conformance".into(),
format!("package={}", pkg),
format!("family={}", tree.root),
],
effort: 3,
category: "mandatory".into(),
description: format!(
"<{}> must be inside <{}>, not directly in <{}>",
edge.child, edge.parent, grandparent
),
message: format!(
"<{}> should be wrapped in <{}> inside <{}>.\n\n\
Replace:\n <{}>\n <{} />\n </{}>\n\n\
With:\n <{}>\n <{}>\n <{} />\n </{}>\n </{}>",
edge.child,
edge.parent,
grandparent,
grandparent,
edge.child,
grandparent,
grandparent,
edge.parent,
edge.child,
edge.parent,
grandparent,
),
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", edge.child),
location: "JSX_COMPONENT".into(),
component: None,
parent: Some(format!("^{}$", grandparent)),
parent_from: Some(pkg.to_string()),
not_parent: None,
not_child: None,
value: None,
from: Some(pkg.to_string()),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "CompositionChange".into(),
component: Some(edge.child.clone()),
replacement: Some(edge.parent.clone()),
..Default::default()
}),
});
}
}
let rule_id = format!(
"sd-conformance-{}-must-be-in-{}",
sanitize(&edge.child),
sanitize(&edge.parent),
);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=conformance".into(),
format!("package={}", pkg),
format!("family={}", tree.root),
],
effort: 1,
category: "mandatory".into(),
description: format!("<{}> must be a child of <{}>", edge.child, edge.parent),
message: format!(
"<{}> must be used inside <{}>.\n\n\
Correct usage:\n <{}>\n <{} />\n </{}>",
edge.child, edge.parent, edge.parent, edge.child, edge.parent,
),
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", edge.child),
location: "JSX_COMPONENT".into(),
component: None,
parent: None,
not_parent: Some(format!("^{}$", edge.parent)),
not_child: None,
parent_from: None,
value: None,
from: Some(pkg.to_string()),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "LlmAssisted".into(),
component: Some(edge.child.clone()),
replacement: Some(edge.parent.clone()),
..Default::default()
}),
});
}
}
for check in conformance_checks {
if let ConformanceCheckType::ExclusiveWrapper {
parent,
allowed_children,
} = &check.check_type
{
let pkg = pkg_for(parent, component_packages);
let allowed_pattern = format!("^({})$", allowed_children.join("|"));
let allowed_list = allowed_children.join(" or ");
let rule_id = format!("sd-conformance-{}-requires-wrapper", sanitize(parent),);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=conformance".into(),
format!("package={}", pkg),
format!("family={}", check.family),
],
effort: 3,
category: "mandatory".into(),
description: format!(
"All children of <{}> must be wrapped in {}",
parent, allowed_list
),
message: format!(
"Components placed directly inside <{}> must be wrapped in <{}>.\n\n\
Replace:\n <{}>\n <SomeComponent />\n </{}>\n\n\
With:\n <{}>\n <{}>\n <SomeComponent />\n </{}>\n </{}>",
parent,
allowed_children.first().unwrap_or(&parent.clone()),
parent,
parent,
parent,
allowed_children.first().unwrap_or(&parent.clone()),
allowed_children.first().unwrap_or(&parent.clone()),
parent,
),
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", parent),
location: "JSX_COMPONENT".into(),
component: None,
parent: None,
not_parent: None,
not_child: Some(allowed_pattern),
parent_from: None,
value: None,
from: Some(pkg.to_string()),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "LlmAssisted".into(),
component: Some(parent.clone()),
replacement: Some(allowed_children.first().unwrap_or(&parent.clone()).clone()),
..Default::default()
}),
});
}
}
rules
}
fn generate_context_rules(
changes: &[SourceLevelChange],
component_packages: &HashMap<String, String>,
) -> Vec<KonveyorRule> {
let mut rules = Vec::new();
for change in changes {
if change.category != SourceLevelCategory::ContextDependency {
continue;
}
let context_name = change
.new_value
.as_ref()
.or(change.old_value.as_ref())
.and_then(|v| {
v.strip_prefix("useContext(")
.and_then(|s| s.strip_suffix(')'))
.or_else(|| {
v.strip_prefix('<')
.and_then(|s| s.strip_suffix(".Provider>"))
})
});
let Some(ctx_name) = context_name else {
continue;
};
let pkg = pkg_for(&change.component, component_packages);
let rule_id = format!(
"sd-context-{}-{}",
sanitize(&change.component),
sanitize(ctx_name),
);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=context-dependency".into(),
format!("package={}", pkg),
format!("component={}", change.component),
],
effort: 3,
category: "mandatory".into(),
description: change.description.clone(),
message: format!(
"{}\n\n\
If you import and use {} directly, review your usage.\n\
The context shape or provider location may have changed.",
change.description, ctx_name,
),
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", ctx_name),
location: "IMPORT".into(),
component: None,
parent: None,
parent_from: None,
not_parent: None,
not_child: None,
value: None,
from: Some(pkg.to_string()),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "Manual".into(),
component: Some(change.component.clone()),
from: change.old_value.clone(),
to: change.new_value.clone(),
..Default::default()
}),
});
}
rules
}
fn generate_prop_child_migration_rules(
report: &AnalysisReport<TypeScript>,
sd: &SdPipelineResult,
component_packages: &HashMap<String, String>,
) -> Vec<KonveyorRule> {
let mut rules = Vec::new();
let mut removed_props: HashMap<String, Vec<RemovedProp>> = HashMap::new();
let mut added_props: HashMap<String, HashSet<String>> = HashMap::new();
for file_changes in &report.changes {
for change in &file_changes.breaking_api_changes {
if let Some(component) = extract_component_name_from_symbol(&change.symbol) {
if let Some(prop) = extract_prop_name_from_symbol(&change.symbol) {
if change.change == ApiChangeType::Removed {
let is_reactnode = change
.before
.as_ref()
.map(|b| is_react_node_type(b))
.unwrap_or(false);
removed_props
.entry(component.clone())
.or_default()
.push(RemovedProp {
name: prop,
component,
is_reactnode,
before_type: change.before.clone(),
});
}
}
}
}
}
if let Some(_new_surface) = report.changes.first() {
for file_changes in &report.changes {
for change in &file_changes.breaking_api_changes {
if change.change == ApiChangeType::Renamed {
if let Some(component) = extract_component_name_from_symbol(&change.symbol) {
if let Some(after) = &change.after {
added_props
.entry(component)
.or_default()
.insert(after.clone());
}
}
}
}
}
}
for tree in &sd.composition_trees {
let new_children: HashSet<&str> = tree
.edges
.iter()
.filter(|e| e.parent == tree.root)
.map(|e| e.child.as_str())
.collect();
let root_removed = removed_props.get(&tree.root);
let Some(root_removed) = root_removed else {
continue;
};
let child_props = get_child_props_from_report(report, sd, &new_children);
let pkg = pkg_for(&tree.root, component_packages);
for removed in root_removed {
for (child_name, child_prop_set) in &child_props {
if child_prop_set.contains(&removed.name) {
let rule_id = format!(
"sd-prop-to-child-{}-{}-to-{}",
sanitize(&tree.root),
sanitize(&removed.name),
sanitize(child_name),
);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=prop-to-child".into(),
format!("package={}", pkg),
format!("family={}", tree.root),
format!("target-component={}", child_name),
],
effort: 3,
category: "mandatory".into(),
description: format!(
"The `{}` prop moved from <{}> to <{}>",
removed.name, tree.root, child_name
),
message: {
let mut msg = format!(
"The `{}` prop has been removed from <{}>.\n\
Use <{} {}={{...}} /> as a child of <{}> instead.\n\n\
Before:\n <{} {}={{value}}>\n ...\n </{}>\n\n\
After:\n <{}>\n <{} {}={{value}} />\n ...\n </{}>",
removed.name,
tree.root,
child_name,
removed.name,
tree.root,
tree.root,
removed.name,
tree.root,
tree.root,
child_name,
removed.name,
tree.root,
);
if let Some(parent_props) = sd.new_component_props.get(&tree.root) {
let staying: Vec<&String> = parent_props
.iter()
.filter(|p| {
p.as_str() != "children" && p.as_str() != "className"
})
.take(10)
.collect();
if !staying.is_empty() {
msg.push_str(&format!(
"\n\nIMPORTANT: These props stay on <{}>: {}.\n\
Do NOT move them to <{}>.",
tree.root,
staying
.iter()
.map(|p| format!("`{}`", p))
.collect::<Vec<_>>()
.join(", "),
child_name,
));
}
}
msg
},
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", removed.name),
location: "JSX_PROP".into(),
component: Some(format!("^{}$", tree.root)),
parent: None,
parent_from: None,
not_parent: None,
not_child: None,
value: None,
from: Some(pkg.to_string()),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "PropToChild".into(),
from: Some(removed.name.clone()),
component: Some(tree.root.clone()),
replacement: Some(child_name.clone()),
prop: Some(removed.name.clone()),
..Default::default()
}),
});
break; }
}
if removed.is_reactnode {
let matched_in_phase1 = rules.iter().any(|r| {
r.labels.iter().any(|l| l == "change-type=prop-to-child")
&& r.fix_strategy
.as_ref()
.map(|fs| fs.from.as_deref() == Some(removed.name.as_str()))
.unwrap_or(false)
});
if !matched_in_phase1 {
for child_name in &new_children {
if child_name
.to_lowercase()
.contains(&removed.name.to_lowercase())
{
let rule_id = format!(
"sd-prop-to-children-{}-{}-to-{}",
sanitize(&tree.root),
sanitize(&removed.name),
sanitize(child_name),
);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=prop-to-child".into(),
format!("package={}", pkg),
format!("family={}", tree.root),
format!("target-component={}", child_name),
],
effort: 3,
category: "mandatory".into(),
description: format!(
"The `{}` prop (ReactNode) moved from <{}> to <{}> children",
removed.name, tree.root, child_name
),
message: format!(
"The `{}` prop has been removed from <{}>.\n\
Pass this content as children of <{}> instead.\n\n\
Before:\n <{} {}={{content}}>\n ...\n </{}>\n\n\
After:\n <{}>\n <{}>{{content}}</{}>\n ...\n </{}>",
removed.name,
tree.root,
child_name,
tree.root,
removed.name,
tree.root,
tree.root,
child_name,
child_name,
tree.root,
),
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", removed.name),
location: "JSX_PROP".into(),
component: Some(format!("^{}$", tree.root)),
parent: None,
not_parent: None,
not_child: None,
parent_from: None,
value: None,
from: Some(pkg.to_string()),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "PropToChildren".into(),
from: Some(removed.name.clone()),
component: Some(tree.root.clone()),
replacement: Some(child_name.to_string()),
..Default::default()
}),
});
break;
}
}
}
}
}
}
for tree in &sd.composition_trees {
let root = &tree.root;
let pkg = pkg_for(root, component_packages);
let old_root_props = sd
.old_component_props
.get(root)
.cloned()
.unwrap_or_default();
let new_root_props = sd
.new_component_props
.get(root)
.cloned()
.unwrap_or_default();
let added_props: BTreeSet<String> = new_root_props
.difference(&old_root_props)
.cloned()
.collect();
if added_props.is_empty() {
continue;
}
let new_prop_types = sd
.new_component_prop_types
.get(root)
.cloned()
.unwrap_or_default();
let old_members: HashSet<&str> = sd
.old_component_props
.keys()
.filter(|name| {
name.starts_with(root.as_str()) && *name != root
})
.map(|s| s.as_str())
.collect();
let new_members: HashSet<&str> = tree.family_members.iter().map(|s| s.as_str()).collect();
let removed_children: Vec<&str> = old_members.difference(&new_members).copied().collect();
for removed_child in &removed_children {
let child_lower = removed_child.to_lowercase();
let child_suffix = child_lower
.strip_prefix(&root.to_lowercase())
.unwrap_or(&child_lower)
.to_lowercase();
if child_suffix.is_empty() {
continue;
}
for added_prop in &added_props {
if added_prop.to_lowercase() == child_suffix {
let is_reactnode = new_prop_types
.get(added_prop)
.map(|t| is_react_node_type(t))
.unwrap_or(false);
let rule_id = format!(
"sd-child-to-prop-{}-{}-to-{}",
sanitize(root),
sanitize(removed_child),
sanitize(added_prop),
);
let message = if is_reactnode {
format!(
"<{}> has been removed. Pass its content via the `{}` prop on <{}> instead.\n\n\
Before:\n <{}>\n <{}>{{}}</{}>\n </{}>\n\n\
After:\n <{} {}={{content}} />",
removed_child, added_prop, root,
root, removed_child, removed_child, root,
root, added_prop,
)
} else {
format!(
"<{}> has been removed. Use the `{}` prop on <{}> instead.\n\n\
Before:\n <{}>\n <{} />\n </{}>\n\n\
After:\n <{} {}={{...}} />",
removed_child,
added_prop,
root,
root,
removed_child,
root,
root,
added_prop,
)
};
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=child-to-prop".into(),
format!("package={}", pkg),
format!("family={}", root),
],
effort: 3,
category: "mandatory".into(),
description: format!(
"<{}> removed — use `{}` prop on <{}> instead",
removed_child, added_prop, root
),
message,
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", removed_child),
location: "JSX_COMPONENT".into(),
component: None,
parent: Some(format!("^{}$", root)),
parent_from: Some(pkg.clone()),
not_parent: None,
not_child: None,
value: None,
from: Some(pkg.clone()),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "ChildToProp".into(),
from: Some(removed_child.to_string()),
to: Some(added_prop.clone()),
component: Some(root.clone()),
prop: Some(added_prop.clone()),
..Default::default()
}),
});
break;
}
}
}
}
rules
}
fn generate_cross_family_child_to_prop_rules(
report: &AnalysisReport<TypeScript>,
sd: &SdPipelineResult,
component_packages: &HashMap<String, String>,
) -> Vec<KonveyorRule> {
let mut rules = Vec::new();
let all_component_names: HashSet<&str> = sd
.component_packages
.keys()
.chain(sd.old_component_packages.keys())
.map(|s| s.as_str())
.collect();
let mut migration_targets: HashMap<String, &semver_analyzer_core::MigrationTarget> =
HashMap::new();
for file_changes in &report.changes {
for change in &file_changes.breaking_api_changes {
if let Some(ref mt) = change.migration_target {
migration_targets.insert(mt.removed_symbol.clone(), mt);
}
}
}
for new_tree in &sd.composition_trees {
let root = &new_tree.root;
let pkg = pkg_for(root, component_packages);
let old_tree = match sd.old_composition_trees.iter().find(|t| t.root == *root) {
Some(t) => t,
None => continue,
};
let old_root_props: BTreeSet<&str> = sd
.old_component_props
.get(root)
.map(|s| s.iter().map(|p| p.as_str()).collect())
.unwrap_or_default();
let new_root_props: BTreeSet<&str> = sd
.new_component_props
.get(root)
.map(|s| s.iter().map(|p| p.as_str()).collect())
.unwrap_or_default();
let added_props: BTreeSet<&str> = new_root_props
.difference(&old_root_props)
.copied()
.collect();
if added_props.is_empty() {
continue;
}
let new_family: HashSet<&str> =
new_tree.family_members.iter().map(|s| s.as_str()).collect();
let new_members: HashSet<&str> =
new_tree.family_members.iter().map(|s| s.as_str()).collect();
for edge in &old_tree.edges {
if new_members.contains(edge.child.as_str()) {
continue;
}
let bem_prop = match &edge.bem_evidence {
Some(evidence) => {
extract_bem_prop_name(evidence)
}
None => continue,
};
let bem_prop = match bem_prop {
Some(p) => p,
None => continue,
};
if !added_props.contains(bem_prop.as_str()) {
continue;
}
let removed_props_iface = format!("{}Props", edge.child);
let has_migration_match = migration_targets
.get(&removed_props_iface)
.map(|mt| {
mt.matching_members
.iter()
.any(|mm| mm.old_name == bem_prop && mm.new_name == bem_prop)
})
.unwrap_or(false);
if !has_migration_match {
continue;
}
let prop_lower = bem_prop.to_lowercase();
for comp_name in &all_component_names {
let comp_lower = comp_name.to_lowercase();
if !prop_lower.starts_with(&comp_lower) {
continue;
}
if new_family.contains(comp_name) {
continue;
}
if *comp_name == edge.child.as_str() {
continue;
}
let comp_pkg = pkg_for(comp_name, component_packages);
let rule_id = format!(
"sd-cross-family-child-to-prop-{}-{}-to-{}",
sanitize(root),
sanitize(comp_name),
sanitize(&bem_prop),
);
let message = format!(
"<{comp}> should no longer be used as a child of <{root}>.\n\
Use the `{prop}` prop on <{root}> instead.\n\n\
Before:\n\
\x20 <{root}>\n\
\x20 <{comp} ...>...</{comp}>\n\
\x20 </{root}>\n\n\
After:\n\
\x20 <{root} {prop}={{...}}>\n\
\x20 ...\n\
\x20 </{root}>\n\n\
The <{removed}> component that previously wrapped this content \
has been removed. Its `{prop}` prop has moved to <{root}>.",
comp = comp_name,
root = root,
prop = bem_prop,
removed = edge.child,
);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=child-to-prop".into(),
format!("package={}", pkg),
format!("family={}", root),
],
effort: 3,
category: "mandatory".into(),
description: format!(
"<{}> inside <{}> — use `{}` prop instead",
comp_name, root, bem_prop
),
message,
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", comp_name),
location: "JSX_COMPONENT".into(),
component: None,
parent: Some(format!("^{}$", root)),
parent_from: Some(pkg.clone()),
not_parent: None,
not_child: None,
value: None,
from: Some(comp_pkg),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "ChildToProp".into(),
from: Some(comp_name.to_string()),
to: Some(bem_prop.clone()),
component: Some(root.clone()),
prop: Some(bem_prop.clone()),
..Default::default()
}),
});
}
}
}
if !rules.is_empty() {
tracing::info!(
count = rules.len(),
"Generated cross-family child→prop migration rules"
);
}
rules
}
fn extract_bem_prop_name(evidence: &str) -> Option<String> {
let start = evidence.find('\'')?;
let rest = &evidence[start + 1..];
let end = rest.find('\'')?;
Some(rest[..end].to_string())
}
fn generate_deprecated_migration_rules(
sd: &SdPipelineResult,
_component_packages: &HashMap<String, String>,
) -> Vec<KonveyorRule> {
let mut rules = Vec::new();
for (component, old_pkg) in &sd.old_component_packages {
let new_pkg = sd.component_packages.get(component);
let old_is_deprecated = old_pkg.contains("/deprecated");
let new_pkg_val = new_pkg.cloned().unwrap_or_default();
let new_is_deprecated = new_pkg_val.contains("/deprecated");
let new_is_main = !new_pkg_val.is_empty()
&& !new_pkg_val.contains("/deprecated")
&& !new_pkg_val.contains("/next");
if old_is_deprecated && !new_is_deprecated {
let main_pkg_name = if new_is_main {
Some(new_pkg_val.clone())
} else {
sd.component_packages
.iter()
.find(|(name, pkg)| {
*name == component && !pkg.contains("/deprecated") && !pkg.contains("/next")
})
.map(|(_, pkg)| pkg.clone())
};
if let Some(main_pkg) = main_pkg_name {
let composition = find_composition_tree_for(component, &sd.composition_trees);
let rule_id = format!(
"sd-deprecated-removed-{}-migrate-to-main",
sanitize(component),
);
let mut message = format!(
"The deprecated `<{}>` from `{}` has been removed.\n\
Migrate to the new `<{}>` from `{}`.\n",
component, old_pkg, component, main_pkg,
);
if let Some(tree) = composition {
message.push_str(&format!(
"\nNew composition structure:\n{}",
format_tree_as_jsx(tree),
));
}
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=deprecated-migration".into(),
format!("package={}", old_pkg),
format!("target-package={}", main_pkg),
],
effort: 5,
category: "mandatory".into(),
description: format!(
"Deprecated <{}> removed — migrate to new API in {}",
component, main_pkg
),
message,
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", component),
location: "IMPORT".into(),
component: None,
parent: None,
parent_from: None,
not_parent: None,
not_child: None,
value: None,
from: Some(old_pkg.clone()),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "DeprecatedMigration".into(),
from: Some(old_pkg.clone()),
to: Some(main_pkg.clone()),
component: Some(component.clone()),
..Default::default()
}),
});
}
continue;
}
if !old_is_deprecated && new_is_deprecated {
let base_pkg = old_pkg.clone();
let deprecated_pkg = format!("{}/deprecated", base_pkg);
let composition = find_composition_tree_for(component, &sd.composition_trees);
let rule_id = format!("sd-deprecated-moved-{}-to-deprecated", sanitize(component));
let mut message = format!(
"`<{}>` from `{}` uses the old API.\n\
Migrate to the new `<{}>` from `{}`.\n",
component, deprecated_pkg, component, base_pkg,
);
if let Some(tree) = composition {
message.push_str(&format!(
"\nNew composition structure:\n{}",
format_tree_as_jsx(tree),
));
}
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=deprecated-migration".into(),
format!("package={}", deprecated_pkg),
format!("target-package={}", base_pkg),
],
effort: 5,
category: "mandatory".into(),
description: format!(
"<{}> from /deprecated — migrate to new API in {}",
component, base_pkg
),
message,
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", component),
location: "IMPORT".into(),
component: None,
parent: None,
parent_from: None,
not_parent: None,
not_child: None,
value: None,
from: Some(deprecated_pkg.clone()),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "DeprecatedMigration".into(),
from: Some(deprecated_pkg),
to: Some(base_pkg),
component: Some(component.clone()),
..Default::default()
}),
});
}
}
rules
}
fn find_composition_tree_for<'a>(
component: &str,
trees: &'a [CompositionTree],
) -> Option<&'a CompositionTree> {
trees.iter().find(|t| t.root == component)
}
fn format_tree_as_jsx(tree: &CompositionTree) -> String {
let mut lines = Vec::new();
let mut parent_children: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for edge in &tree.edges {
if edge.relationship != ChildRelationship::Internal {
parent_children
.entry(edge.parent.as_str())
.or_default()
.push(edge.child.as_str());
}
}
fn render(
component: &str,
parent_children: &BTreeMap<&str, Vec<&str>>,
indent: usize,
lines: &mut Vec<String>,
visited: &mut HashSet<String>,
) {
let pad = " ".repeat(indent);
if !visited.insert(component.to_string()) || indent > 5 {
lines.push(format!("{}<{} />", pad, component));
return;
}
if let Some(children) = parent_children.get(component) {
lines.push(format!("{}<{}>", pad, component));
for child in children {
render(child, parent_children, indent + 1, lines, visited);
}
lines.push(format!("{}</{}>", pad, component));
} else {
lines.push(format!("{}<{} />", pad, component));
}
visited.remove(component);
}
let mut visited = HashSet::new();
render(&tree.root, &parent_children, 1, &mut lines, &mut visited);
lines.join("\n")
}
struct RemovedProp {
name: String,
#[allow(dead_code)]
component: String,
is_reactnode: bool,
#[allow(dead_code)]
before_type: Option<String>,
}
fn generate_prop_value_conformance_rules(
report: &AnalysisReport<crate::language::TypeScript>,
sd: &SdPipelineResult,
component_packages: &HashMap<String, String>,
) -> Vec<KonveyorRule> {
let mut rules = Vec::new();
for fc in &report.changes {
for api in &fc.breaking_api_changes {
if api.change != ApiChangeType::TypeChanged {
continue;
}
let symbol = &api.symbol;
if !symbol.contains('.') {
continue;
}
let component = match extract_component_name_from_symbol(symbol) {
Some(c) => c,
None => continue,
};
let prop = match extract_prop_name_from_symbol(symbol) {
Some(p) => p,
None => continue,
};
let before = match &api.before {
Some(b) => b,
None => continue,
};
let after = match &api.after {
Some(a) => a,
None => continue,
};
let old_values: HashSet<String> = extract_union_values(before);
let new_values: HashSet<String> = extract_union_values(after);
if old_values.is_empty() {
continue;
}
let removed: Vec<&String> = old_values.difference(&new_values).collect();
if removed.is_empty() {
continue;
}
let pkg = pkg_for(&component, component_packages);
for value in &removed {
let rule_id = format!(
"sd-prop-value-{}-{}-{}",
sanitize(&component),
sanitize(&prop),
sanitize(value),
);
let replacement_hint = find_replacement_value(value, &new_values);
let message = if let Some(ref replacement) = replacement_hint {
format!(
"The value \"{}\" is no longer valid for the `{}` prop on <{}>.\n\
Use \"{}\" instead.\n\n\
Old: <{component} {prop}=\"{value}\" />\n\
New: <{component} {prop}=\"{replacement}\" />",
value,
prop,
component,
replacement,
component = component,
prop = prop,
value = value,
replacement = replacement,
)
} else {
format!(
"The value \"{}\" is no longer valid for the `{}` prop on <{}>.\n\
Valid values: {}",
value,
prop,
component,
new_values
.iter()
.map(|v| format!("\"{}\"", v))
.collect::<Vec<_>>()
.join(", "),
)
};
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=prop-value-removed".into(),
format!("package={}", pkg),
],
effort: 1,
category: "mandatory".into(),
description: format!(
"Value \"{}\" removed from `{}` prop on <{}>",
value, prop, component,
),
message,
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", prop),
location: "JSX_PROP".into(),
component: Some(format!("^{}$", component)),
parent: None,
not_parent: None,
not_child: None,
parent_from: None,
value: Some(format!("^{}$", regex::escape(value))),
from: Some(pkg.to_string()),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "PropValueChange".into(),
component: Some(component.clone()),
prop: Some(prop.clone()),
from: Some(value.to_string()),
replacement: replacement_hint,
..Default::default()
}),
});
}
}
}
for fc in &report.changes {
for api in &fc.breaking_api_changes {
if api.change != ApiChangeType::Renamed {
continue;
}
let symbol = &api.symbol;
if !symbol.contains('.') {
continue;
}
let component = match extract_component_name_from_symbol(symbol) {
Some(c) => c,
None => continue,
};
let old_prop = match extract_prop_name_from_symbol(symbol) {
Some(p) => p,
None => continue,
};
let new_prop = match &api.after {
Some(a) => a.clone(),
None => continue,
};
let old_type = sd
.old_component_prop_types
.get(&component)
.and_then(|m| m.get(&old_prop));
let new_type = sd
.new_component_prop_types
.get(&component)
.and_then(|m| m.get(&new_prop));
let (old_type, new_type) = match (old_type, new_type) {
(Some(o), Some(n)) => (o, n),
_ => continue,
};
let old_values = extract_union_values(old_type);
let new_values = extract_union_values(new_type);
if old_values.is_empty() || new_values.is_empty() {
continue;
}
let removed: Vec<&String> = old_values.difference(&new_values).collect();
if removed.is_empty() {
continue;
}
let pkg = pkg_for(&component, component_packages);
for value in &removed {
let replacement_hint = find_replacement_value(value, &new_values);
for prop in &[&old_prop, &new_prop] {
let rule_id = format!(
"sd-prop-value-{}-{}-{}",
sanitize(&component),
sanitize(prop),
sanitize(value),
);
let message = if let Some(ref replacement) = replacement_hint {
format!(
"The value \"{value}\" is no longer valid for the `{prop}` prop on <{component}>.\n\
Use \"{replacement}\" instead.\n\n\
Old: <{component} {prop}=\"{value}\" />\n\
New: <{component} {prop}=\"{replacement}\" />\n\n\
Note: `{old_prop}` was renamed to `{new_prop}`.",
value = value,
prop = prop,
component = component,
replacement = replacement,
old_prop = old_prop,
new_prop = new_prop,
)
} else {
let valid = new_values
.iter()
.map(|v| format!("\"{}\"", v))
.collect::<Vec<_>>()
.join(", ");
format!(
"The value \"{value}\" is no longer valid for the `{prop}` prop on <{component}>.\n\
Note: `{old_prop}` was renamed to `{new_prop}`.\n\
Valid values: {valid}",
value = value,
prop = prop,
component = component,
old_prop = old_prop,
new_prop = new_prop,
valid = valid,
)
};
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=prop-value-removed".into(),
format!("package={}", pkg),
],
effort: 1,
category: "mandatory".into(),
description: format!(
"Value \"{}\" removed from `{}` prop on <{}> (renamed from `{}`)",
value, prop, component, old_prop,
),
message,
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", prop),
location: "JSX_PROP".into(),
component: Some(format!("^{}$", component)),
parent: None,
not_parent: None,
not_child: None,
parent_from: None,
value: Some(format!("^{}$", regex::escape(value))),
from: Some(pkg.to_string()),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "PropValueChange".into(),
component: Some(component.clone()),
prop: Some(prop.to_string()),
from: Some(value.to_string()),
replacement: replacement_hint.clone(),
..Default::default()
}),
});
}
}
}
}
rules
}
fn extract_union_values(type_str: &str) -> HashSet<String> {
let re = regex::Regex::new(r"'([^']+)'").unwrap();
re.captures_iter(type_str)
.map(|c| c[1].to_string())
.collect()
}
fn find_replacement_value(removed: &str, new_values: &HashSet<String>) -> Option<String> {
let mappings = [
("light", "secondary"),
("dark", "secondary"),
("darker", "secondary"),
("light-200", "secondary"),
("light300", "secondary"),
("tertiary", "secondary"),
("cyan", "teal"),
("gold", "yellow"),
("alignLeft", "start"),
("alignRight", "end"),
("button-group", "action-group"),
("icon-button-group", "action-group-plain"),
("chip-group", "label-group"),
("TableComposable", "default"),
];
for (old, new) in &mappings {
if removed == *old && new_values.contains(*new) {
return Some(new.to_string());
}
}
None
}
fn generate_required_prop_added_rules(
sd: &SdPipelineResult,
component_packages: &HashMap<String, String>,
) -> Vec<KonveyorRule> {
let mut rules = Vec::new();
for (component, required) in &sd.new_required_props {
let old_props = sd.old_component_props.get(component);
let old_required = old_props.cloned().unwrap_or_default();
let newly_required: Vec<&String> = required
.iter()
.filter(|p| !old_required.contains(*p))
.filter(|p| p.as_str() != "children")
.collect();
if newly_required.is_empty() {
continue;
}
let pkg = pkg_for(component, component_packages);
for prop in &newly_required {
let rule_id = format!(
"sd-required-prop-{}-{}",
sanitize(component),
sanitize(prop),
);
let type_hint = sd
.new_component_prop_types
.get(component)
.and_then(|types| types.get(*prop))
.map(|t| format!(" (type: `{}`)", t))
.unwrap_or_default();
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=required-prop-added".into(),
format!("package={}", pkg),
],
effort: 1,
category: "mandatory".into(),
description: format!(
"<{}> now requires the `{}` prop{}",
component, prop, type_hint,
),
message: format!(
"<{}> has a new required prop `{}`{}.\n\
This prop must be provided — omitting it will cause a TypeScript error.\n\n\
Add the prop: <{} {}={{...}} />",
component, prop, type_hint, component, prop,
),
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", component),
location: "JSX_COMPONENT".into(),
component: None,
parent: None,
not_parent: None,
not_child: None,
parent_from: None,
value: None,
from: Some(pkg.to_string()),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "LlmAssisted".into(),
component: Some(component.clone()),
prop: Some(prop.to_string()),
..Default::default()
}),
});
}
}
rules
}
const ROLE_QUERY_PATTERN: &str =
"^(getByRole|queryByRole|findByRole|getAllByRole|queryAllByRole|findAllByRole)$";
const LABEL_QUERY_PATTERN: &str =
"^(getByLabelText|queryByLabelText|findByLabelText|getAllByLabelText|queryAllByLabelText|findAllByLabelText)$";
const TEST_FILE_PATTERN: &str = ".*\\.(test|spec)\\.(ts|tsx|js|jsx)$";
fn implicit_aria_role(element: &str) -> Option<&'static str> {
match element {
"button" => Some("button"),
"input" => Some("textbox"),
"a" => Some("link"),
"img" => Some("img"),
"select" => Some("combobox"),
"textarea" => Some("textbox"),
"table" => Some("table"),
"tr" => Some("row"),
"td" => Some("cell"),
"th" => Some("columnheader"),
"ul" | "ol" => Some("list"),
"li" => Some("listitem"),
"nav" => Some("navigation"),
"main" => Some("main"),
"header" => Some("banner"),
"footer" => Some("contentinfo"),
"form" => Some("form"),
"dialog" => Some("dialog"),
"article" => Some("article"),
"section" => Some("region"),
"aside" => Some("complementary"),
"progress" => Some("progressbar"),
_ => None,
}
}
fn is_concrete_value(value: &str) -> bool {
!value.starts_with('{') && value != "true" && value != "false"
}
fn generate_test_impact_rules(
changes: &[SourceLevelChange],
component_packages: &HashMap<String, String>,
) -> Vec<KonveyorRule> {
let mut rules = Vec::new();
for change in changes {
if !change.has_test_implications {
continue;
}
let pkg = pkg_for(&change.component, component_packages);
match change.category {
SourceLevelCategory::RoleChange => {
if let Some(ref old_val) = change.old_value {
if !is_concrete_value(old_val) {
continue;
}
let rule_id = format!(
"sd-test-{}-role-{}-{}",
sanitize(&change.component),
sanitize(old_val),
if change.new_value.is_some() {
"changed"
} else {
"removed"
},
);
let message = if let Some(ref new_val) = change.new_value {
if is_concrete_value(new_val) {
format!(
"{} role changed from '{}' to '{}'.\n\n\
Update test queries:\n \
getByRole('{}') → getByRole('{}')",
change.component, old_val, new_val, old_val, new_val
)
} else {
format!(
"{} role '{}' changed to a dynamic value.\n\n\
Tests using getByRole('{}') may need updating.\n\n\
{}",
change.component, old_val, old_val, change.description
)
}
} else {
format!(
"{} no longer has role='{}'.\n\n\
Tests using getByRole('{}') to find this component will fail.\n\n\
{}",
change.component, old_val, old_val, change.description
)
};
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=test-impact".into(),
"impact=frontend-testing".into(),
format!("package={}", pkg),
],
effort: 1,
category: "optional".into(),
description: format!(
"Test impact: {} role '{}' {}",
change.component,
old_val,
if change.new_value.is_some() {
"changed"
} else {
"removed"
}
),
message,
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: ROLE_QUERY_PATTERN.into(),
location: "FUNCTION_CALL".into(),
component: None,
parent: None,
not_parent: None,
not_child: None,
parent_from: None,
value: Some(format!("^{}$", old_val)),
from: None,
file_pattern: Some(TEST_FILE_PATTERN.into()),
},
},
fix_strategy: None,
});
}
}
SourceLevelCategory::AriaChange => {
if !change.description.contains("aria-label") {
continue;
}
if let Some(ref old_val) = change.old_value {
if !is_concrete_value(old_val) {
continue;
}
let rule_id = format!(
"sd-test-{}-aria-label-{}-{}",
sanitize(&change.component),
sanitize(old_val),
if change.new_value.is_some() {
"changed"
} else {
"removed"
},
);
let message = if let Some(ref new_val) = change.new_value {
if is_concrete_value(new_val) {
format!(
"{} aria-label changed from '{}' to '{}'.\n\n\
Update test queries:\n \
getByLabelText('{}') → getByLabelText('{}')",
change.component, old_val, new_val, old_val, new_val
)
} else {
format!(
"{} aria-label '{}' changed to a dynamic value.\n\n\
Tests using getByLabelText('{}') may need updating.\n\n\
{}",
change.component, old_val, old_val, change.description
)
}
} else {
format!(
"{} no longer has aria-label='{}'.\n\n\
Tests using getByLabelText('{}') to find this component will fail.\n\n\
{}",
change.component, old_val, old_val, change.description
)
};
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=test-impact".into(),
"impact=frontend-testing".into(),
format!("package={}", pkg),
],
effort: 1,
category: "optional".into(),
description: format!(
"Test impact: {} aria-label '{}' {}",
change.component,
old_val,
if change.new_value.is_some() {
"changed"
} else {
"removed"
}
),
message,
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: LABEL_QUERY_PATTERN.into(),
location: "FUNCTION_CALL".into(),
component: None,
parent: None,
not_parent: None,
not_child: None,
parent_from: None,
value: Some(format!("^{}$", old_val)),
from: None,
file_pattern: Some(TEST_FILE_PATTERN.into()),
},
},
fix_strategy: None,
});
}
}
SourceLevelCategory::DomStructure => {
if let Some(ref old_val) = change.old_value {
let element = old_val
.trim_start_matches('<')
.split('>')
.next()
.unwrap_or("")
.trim();
if let Some(role) = implicit_aria_role(element) {
let rule_id = format!(
"sd-test-{}-dom-{}-removed",
sanitize(&change.component),
sanitize(element),
);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=test-impact".into(),
"impact=frontend-testing".into(),
format!("package={}", pkg),
],
effort: 1,
category: "optional".into(),
description: format!(
"Test impact: {} no longer renders <{}>",
change.component, element
),
message: format!(
"{} no longer renders a <{}> element (implicit role='{}').\n\n\
Tests using getByRole('{}') inside {} may fail.\n\n\
{}",
change.component,
element,
role,
role,
change.component,
change.description,
),
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: ROLE_QUERY_PATTERN.into(),
location: "FUNCTION_CALL".into(),
component: None,
parent: None,
not_parent: None,
not_child: None,
parent_from: None,
value: Some(format!("^{}$", role)),
from: None,
file_pattern: Some(TEST_FILE_PATTERN.into()),
},
},
fix_strategy: None,
});
}
}
}
_ => {}
}
}
rules
}
fn generate_prop_attribute_override_rules(
changes: &[SourceLevelChange],
_sd: &SdPipelineResult,
component_packages: &HashMap<String, String>,
) -> Vec<KonveyorRule> {
let mut rules = Vec::new();
for change in changes {
if change.category != SourceLevelCategory::PropAttributeOverride {
continue;
}
if change.old_value.is_some() && change.new_value.is_none() {
continue;
}
let pkg = pkg_for(&change.component, component_packages);
let (prop_name, overridden_attrs) = match &change.new_value {
Some(val) => {
let parts: Vec<&str> = val.splitn(2, " → ").collect();
if parts.len() == 2 {
let attrs: Vec<String> =
parts[1].split(", ").map(|s| s.trim().to_string()).collect();
(parts[0].to_string(), attrs)
} else {
continue;
}
}
None => continue,
};
for attr in &overridden_attrs {
let rule_id = format!(
"sd-prop-override-{}-{}",
sanitize(&change.component),
sanitize(attr),
);
let message = format!(
"The <{component}> component internally generates the `{attr}` HTML \
attribute from the `{prop}` prop via its internal helper. If you pass \
`{attr}` as an HTML attribute, it will be silently overridden.\n\n\
Use the `{prop}` prop instead:\n\n\
Before: <{component} {attr}=\"value\" />\n\
After: <{component} {prop}=\"value\" />",
component = change.component,
attr = attr,
prop = prop_name,
);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=prop-attribute-override".into(),
"has-codemod=false".into(),
format!("package={}", pkg),
],
effort: 3,
category: "mandatory".into(),
description: format!(
"{} manages `{}` internally via the `{}` prop",
change.component, attr, prop_name,
),
message,
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", regex_escape(attr)),
location: "JSX_PROP".into(),
component: Some(format!("^{}$", regex_escape(&change.component))),
parent: None,
not_parent: None,
not_child: None,
parent_from: None,
value: None,
from: if pkg != "unknown" {
Some(pkg.clone())
} else {
None
},
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry::new("LlmAssisted")),
});
}
}
rules
}
fn regex_escape(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '\\' | '^' | '$' | '|' => {
result.push('\\');
result.push(c);
}
_ => result.push(c),
}
}
result
}
const CSS_FILE_PATTERN: &str = ".*\\.css$";
fn generate_css_class_removal_rules(removed_blocks: &[String]) -> Vec<KonveyorRule> {
let mut rules = Vec::new();
for block in removed_blocks {
let pattern = format!("pf-(v5|v6)-c-{}", block);
let rule_id = format!("sd-css-removed-{}", block);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".into(),
"change-type=css-removal".into(),
"impact=visual-regression".into(),
],
effort: 3,
category: "mandatory".into(),
description: format!("CSS component class 'pf-c-{}' was removed in PF v6", block),
message: format!(
"This CSS references the 'pf-c-{}' component class which was removed \
in PatternFly v6.\n\n\
The {} component was rebuilt and no longer uses this CSS class. \
This CSS override is dead and should be removed.\n\n\
Check if the behavior you were overriding is now available via a \
component prop instead.",
block,
block_to_component_name(block),
),
links: vec![],
when: KonveyorCondition::FrontendCssClass {
cssclass: FrontendPatternFields {
pattern,
file_pattern: Some(CSS_FILE_PATTERN.into()),
},
},
fix_strategy: None,
});
}
rules
}
fn block_to_component_name(block: &str) -> String {
block
.split('-')
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
})
.collect()
}
fn extract_component_name_from_symbol(symbol: &str) -> Option<String> {
let parts: Vec<&str> = symbol.split('.').collect();
if parts.len() >= 2 {
let iface = parts[0];
Some(iface.strip_suffix("Props").unwrap_or(iface).to_string())
} else {
None
}
}
fn extract_prop_name_from_symbol(symbol: &str) -> Option<String> {
let parts: Vec<&str> = symbol.split('.').collect();
if parts.len() >= 2 {
Some(parts[1..].join("."))
} else {
None
}
}
fn is_react_node_type(type_str: &str) -> bool {
let t = type_str.trim();
t.contains("ReactNode")
|| t.contains("ReactElement")
|| t.contains("JSX.Element")
|| t.contains("React.ReactNode")
|| t.contains("React.ReactElement")
}
fn get_child_props_from_report(
report: &AnalysisReport<TypeScript>,
sd: &SdPipelineResult,
new_children: &HashSet<&str>,
) -> HashMap<String, HashSet<String>> {
let mut child_props: HashMap<String, HashSet<String>> = HashMap::new();
for child in new_children {
child_props.insert(child.to_string(), HashSet::new());
}
for file_changes in &report.changes {
for change in &file_changes.breaking_api_changes {
if let Some(component) = extract_component_name_from_symbol(&change.symbol) {
if new_children.contains(component.as_str()) {
if let Some(prop) = extract_prop_name_from_symbol(&change.symbol) {
child_props.entry(component).or_default().insert(prop);
}
}
}
}
}
for pkg in &report.packages {
for comp in &pkg.type_summaries {
if new_children.contains(comp.name.as_str()) {
for tc in &comp.type_changes {
child_props
.entry(comp.name.clone())
.or_default()
.insert(tc.property.clone());
}
}
}
}
for (name, profile) in &sd.new_profiles {
if new_children.contains(name.as_str()) {
for prop_name in profile.prop_defaults.keys() {
child_props
.entry(name.clone())
.or_default()
.insert(prop_name.clone());
}
}
}
for (name, props) in &sd.new_component_props {
if new_children.contains(name.as_str()) {
for prop_name in props {
child_props
.entry(name.clone())
.or_default()
.insert(prop_name.clone());
}
}
}
child_props
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c.to_lowercase().next().unwrap_or(c)
} else {
'-'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_component_name() {
assert_eq!(
extract_component_name_from_symbol("ModalProps.title"),
Some("Modal".into())
);
assert_eq!(
extract_component_name_from_symbol("ButtonProps.variant"),
Some("Button".into())
);
assert_eq!(extract_component_name_from_symbol("Button"), None);
}
#[test]
fn test_extract_prop_name() {
assert_eq!(
extract_prop_name_from_symbol("ModalProps.title"),
Some("title".into())
);
assert_eq!(extract_prop_name_from_symbol("Button"), None);
}
#[test]
fn test_is_react_node_type() {
assert!(is_react_node_type("React.ReactNode"));
assert!(is_react_node_type("ReactElement<any>"));
assert!(is_react_node_type("JSX.Element"));
assert!(!is_react_node_type("string"));
assert!(!is_react_node_type("boolean"));
}
#[test]
fn test_sanitize() {
assert_eq!(sanitize("ModalHeader"), "modalheader");
assert_eq!(sanitize("Dropdown.Item"), "dropdown-item");
}
#[test]
fn test_extract_bem_prop_name() {
assert_eq!(
extract_bem_prop_name(
"EmptyStateHeader is BEM element 'titleText' of emptyState block"
),
Some("titleText".into())
);
assert_eq!(
extract_bem_prop_name("FooBar is BEM element 'icon' of foo block"),
Some("icon".into())
);
assert_eq!(extract_bem_prop_name("no quotes here"), None);
}
fn test_pkg_map() -> HashMap<String, String> {
let mut m = HashMap::new();
m.insert("Dropdown".into(), "@patternfly/react-core".into());
m.insert("DropdownList".into(), "@patternfly/react-core".into());
m.insert("DropdownItem".into(), "@patternfly/react-core".into());
m.insert("AccordionContent".into(), "@patternfly/react-core".into());
m.insert("AccordionItem".into(), "@patternfly/react-core".into());
m
}
#[test]
fn test_conformance_invalid_direct_child() {
let tree = CompositionTree {
root: "Dropdown".into(),
family_members: vec![
"Dropdown".into(),
"DropdownList".into(),
"DropdownItem".into(),
],
edges: vec![
semver_analyzer_core::types::sd::CompositionEdge {
parent: "Dropdown".into(),
child: "DropdownList".into(),
relationship: ChildRelationship::DirectChild,
required: true,
bem_evidence: None,
},
semver_analyzer_core::types::sd::CompositionEdge {
parent: "DropdownList".into(),
child: "DropdownItem".into(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence: None,
},
],
};
let rules = generate_conformance_rules(&[tree], &[], &test_pkg_map());
let invalid_rule = rules
.iter()
.find(|r| r.rule_id.contains("dropdownitem-not-in-dropdown"));
assert!(
invalid_rule.is_some(),
"Expected InvalidDirectChild rule for DropdownItem in Dropdown, got rules: {:?}",
rules.iter().map(|r| &r.rule_id).collect::<Vec<_>>()
);
if let KonveyorCondition::FrontendReferenced { referenced } = &invalid_rule.unwrap().when {
assert_eq!(referenced.pattern, "^DropdownItem$");
assert_eq!(referenced.parent.as_deref(), Some("^Dropdown$"));
} else {
panic!("Expected FrontendReferenced condition");
}
}
#[test]
fn test_context_rule_generation() {
let changes = vec![SourceLevelChange {
component: "AccordionItem".into(),
category: SourceLevelCategory::ContextDependency,
description: "AccordionItem now provides AccordionItemContext".into(),
old_value: None,
new_value: Some("<AccordionItemContext.Provider>".into()),
has_test_implications: true,
test_description: None,
}];
let rules = generate_context_rules(&changes, &test_pkg_map());
assert_eq!(rules.len(), 1);
assert!(rules[0].rule_id.contains("accordionitemcontext"));
if let KonveyorCondition::FrontendReferenced { referenced } = &rules[0].when {
assert_eq!(referenced.pattern, "^AccordionItemContext$");
assert_eq!(referenced.location, "IMPORT");
} else {
panic!("Expected FrontendReferenced condition");
}
}
}