use crate::types::{MemberMapping, MigrationTarget, Symbol, SymbolKind};
use std::collections::{HashMap, HashSet};
struct MigrationCandidate<'a, M: Default + Clone + PartialEq> {
target: &'a Symbol<M>,
overlap_ratio: f64,
matching_members: Vec<MemberMapping>,
removed_only: Vec<String>,
}
const MIN_OVERLAP_RATIO: f64 = 0.25;
fn min_overlap_count(member_count: usize) -> usize {
match member_count {
0 => 1,
1..=3 => 1,
4..=6 => 2,
_ => 3,
}
}
#[derive(Debug)]
pub(super) struct MigrationMatch<'a, M: Default + Clone + PartialEq = ()> {
pub removed: &'a Symbol<M>,
pub target: MigrationTarget,
}
pub(super) fn detect_migrations<'a, M, S>(
removed: &[&'a Symbol<M>],
old_symbols: &[&'a Symbol<M>],
new_symbols: &[&'a Symbol<M>],
semantics: &S,
dir_renames: &HashMap<String, Vec<String>>,
) -> Vec<MigrationMatch<'a, M>>
where
M: Default + Clone + PartialEq,
S: crate::traits::LanguageSemantics<M>,
{
let removed_interfaces: Vec<&&Symbol<M>> = removed
.iter()
.filter(|s| is_container_kind(s.kind))
.collect();
if removed_interfaces.is_empty() {
return Vec::new();
}
let new_containers: Vec<&Symbol<M>> = new_symbols
.iter()
.filter(|s| is_container_kind(s.kind))
.copied()
.collect();
let old_by_qname: HashMap<&str, &Symbol<M>> = old_symbols
.iter()
.filter(|s| is_container_kind(s.kind))
.map(|s| (s.qualified_name.as_str(), *s))
.collect();
let mut results = Vec::new();
let mut matched_removed: HashSet<&str> = HashSet::new();
for removed_sym in &removed_interfaces {
let removed_members: HashSet<&str> = removed_sym
.members
.iter()
.map(|m| m.name.as_str())
.collect();
if removed_members.is_empty() {
continue;
}
let mut candidates: Vec<&&Symbol<M>> = new_containers
.iter()
.filter(|c| semantics.same_family(removed_sym, c))
.collect();
if candidates.is_empty() && !dir_renames.is_empty() {
let removed_dir = removed_sym
.file
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if let Some(target_dirs) = dir_renames.get(&removed_dir) {
candidates = new_containers
.iter()
.filter(|c| {
c.file
.parent()
.map(|p| {
let ps = p.to_string_lossy();
target_dirs.iter().any(|td| ps == td.as_str())
})
.unwrap_or(false)
})
.collect();
if !candidates.is_empty() {
tracing::debug!(
removed = %removed_sym.name,
from_dir = %removed_dir,
target_dirs = ?target_dirs,
candidate_count = candidates.len(),
"Cross-directory fallback via rename chain"
);
}
}
}
let mut best_match: Option<MigrationCandidate<M>> = None;
for candidate in candidates {
if candidate.qualified_name == removed_sym.qualified_name {
continue;
}
let candidate_members: HashSet<&str> =
if let Some(old_candidate) = old_by_qname.get(candidate.qualified_name.as_str()) {
let old_members: HashSet<&str> = old_candidate
.members
.iter()
.map(|m| m.name.as_str())
.collect();
candidate
.members
.iter()
.map(|m| m.name.as_str())
.filter(|name| !old_members.contains(name))
.collect()
} else {
candidate.members.iter().map(|m| m.name.as_str()).collect()
};
let is_same_identity = semantics.same_identity(removed_sym, candidate);
let effective_candidate_members: HashSet<&str> = if is_same_identity {
candidate.members.iter().map(|m| m.name.as_str()).collect()
} else {
candidate_members
};
if effective_candidate_members.is_empty() {
continue;
}
let matching: Vec<MemberMapping> = removed_members
.iter()
.filter(|m| effective_candidate_members.contains(*m))
.map(|m| MemberMapping {
old_name: m.to_string(),
new_name: m.to_string(),
})
.collect();
if matching.len() < min_overlap_count(removed_members.len()) {
continue;
}
let ratio_removed = matching.len() as f64 / removed_members.len() as f64;
let ratio_new = matching.len() as f64 / effective_candidate_members.len() as f64;
let best_ratio = ratio_removed.max(ratio_new);
if best_ratio < MIN_OVERLAP_RATIO {
continue;
}
let removed_only_names: Vec<&str> = removed_members
.iter()
.filter(|m| !effective_candidate_members.contains(*m))
.copied()
.collect();
let added_only_names: Vec<&str> = effective_candidate_members
.iter()
.filter(|m| !removed_members.contains(*m))
.copied()
.collect();
let mut fuzzy_matches: Vec<MemberMapping> = Vec::new();
const MAX_FUZZY_SET_SIZE: usize = 8;
const FUZZY_MIN_SIMILARITY: f64 = 0.60;
if removed_only_names.len() <= MAX_FUZZY_SET_SIZE
&& added_only_names.len() <= MAX_FUZZY_SET_SIZE
&& !removed_only_names.is_empty()
&& !added_only_names.is_empty()
{
let unmatched_removed: Vec<&Symbol<M>> = removed_sym
.members
.iter()
.filter(|m| removed_only_names.contains(&m.name.as_str()))
.collect();
let unmatched_added: Vec<&Symbol<M>> = candidate
.members
.iter()
.filter(|m| added_only_names.contains(&m.name.as_str()))
.collect();
if !unmatched_removed.is_empty() && !unmatched_added.is_empty() {
let prop_renames = super::rename::detect_renames(
&unmatched_removed,
&unmatched_added,
|_, _| true,
semantics.primitive_type_names(),
);
for prm in &prop_renames {
let sim = super::rename::name_similarity(&prm.old.name, &prm.new.name);
if sim < FUZZY_MIN_SIMILARITY {
continue;
}
let old_rt = prm
.old
.signature
.as_ref()
.and_then(|s| s.return_type.as_deref());
let new_rt = prm
.new
.signature
.as_ref()
.and_then(|s| s.return_type.as_deref());
let types_ok = match (old_rt, new_rt) {
(Some(o), Some(n)) => super::compare::types_structurally_similar(
o,
n,
semantics.primitive_type_names(),
),
_ => true, };
if !types_ok {
tracing::debug!(
old = %prm.old.name,
new = %prm.new.name,
old_type = old_rt.unwrap_or("?"),
new_type = new_rt.unwrap_or("?"),
"Fuzzy prop match rejected: type-incompatible"
);
continue;
}
tracing::debug!(
old = %prm.old.name,
new = %prm.new.name,
similarity = sim,
"Fuzzy prop rename detected in migration"
);
fuzzy_matches.push(MemberMapping {
old_name: prm.old.name.clone(),
new_name: prm.new.name.clone(),
});
}
}
}
let mut all_matching = matching;
let fuzzy_old_names: HashSet<String> =
fuzzy_matches.iter().map(|m| m.old_name.clone()).collect();
all_matching.extend(fuzzy_matches);
let removed_only: Vec<String> = removed_only_names
.iter()
.filter(|m| !fuzzy_old_names.contains(**m))
.map(|m| m.to_string())
.collect();
if best_match
.as_ref()
.is_none_or(|m| best_ratio > m.overlap_ratio)
{
best_match = Some(MigrationCandidate {
target: candidate,
overlap_ratio: best_ratio,
matching_members: all_matching,
removed_only,
});
}
}
if let Some(MigrationCandidate {
target: replacement,
overlap_ratio: ratio,
matching_members,
removed_only: removed_only_members,
}) = best_match
{
if !matched_removed.contains(removed_sym.qualified_name.as_str()) {
matched_removed.insert(&removed_sym.qualified_name);
let old_extends = removed_sym.extends.clone();
let new_extends = replacement.extends.clone();
let (old_ext, new_ext) = if old_extends != new_extends {
(old_extends, new_extends)
} else {
(None, None)
};
results.push(MigrationMatch {
removed: removed_sym,
target: MigrationTarget {
removed_symbol: removed_sym.name.clone(),
removed_qualified_name: removed_sym.qualified_name.clone(),
removed_package: removed_sym.package.clone(),
replacement_symbol: replacement.name.clone(),
replacement_qualified_name: replacement.qualified_name.clone(),
replacement_package: replacement.package.clone(),
matching_members,
removed_only_members,
overlap_ratio: ratio,
old_extends: old_ext,
new_extends: new_ext,
},
});
}
}
}
results
}
fn is_container_kind(kind: SymbolKind) -> bool {
matches!(
kind,
SymbolKind::Interface | SymbolKind::Class | SymbolKind::Enum
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff::MinimalSemantics;
use crate::types::{Symbol, SymbolKind, Visibility};
fn make_interface(name: &str, file: &str, members: &[&str]) -> Symbol {
let mut sym = Symbol::new(
name,
format!("{}.{}", file, name),
SymbolKind::Interface,
Visibility::Exported,
file,
1,
);
for member_name in members {
sym.members.push(Symbol::new(
*member_name,
format!("{}.{}.{}", file, name, member_name),
SymbolKind::Property,
Visibility::Public,
file,
1,
));
}
sym
}
#[test]
fn test_merge_child_into_parent_emptystate_pattern() {
let old_header = make_interface(
"EmptyStateHeaderProps",
"components/EmptyState/EmptyStateHeader.d.ts",
&[
"children",
"className",
"headingLevel",
"icon",
"titleClassName",
"titleText",
],
);
let old_parent = make_interface(
"EmptyStateProps",
"components/EmptyState/EmptyState.d.ts",
&["children", "className", "variant"],
);
let new_parent = make_interface(
"EmptyStateProps",
"components/EmptyState/EmptyState.d.ts",
&[
"children",
"className",
"variant",
"titleText",
"headingLevel",
"icon",
"status",
"titleClassName",
"headerClassName",
],
);
let old_symbols: Vec<&Symbol> = vec![&old_header, &old_parent];
let new_symbols: Vec<&Symbol> = vec![&new_parent];
let removed: Vec<&Symbol> = vec![&old_header];
let results = detect_migrations(
&removed,
&old_symbols,
&new_symbols,
&MinimalSemantics,
&HashMap::<String, Vec<String>>::new(),
);
assert_eq!(results.len(), 1);
let m = &results[0];
assert_eq!(m.target.removed_symbol, "EmptyStateHeaderProps");
assert_eq!(m.target.replacement_symbol, "EmptyStateProps");
assert!(
m.target.overlap_ratio > 0.5,
"Expected >50% overlap, got {}",
m.target.overlap_ratio
);
assert!(
m.target.matching_members.len() >= 4,
"Expected >= 4 matching members, got {}: {:?}",
m.target.matching_members.len(),
m.target
.matching_members
.iter()
.map(|m| &m.old_name)
.collect::<Vec<_>>()
);
}
#[test]
fn test_same_name_replacement_select_pattern() {
let old_select = make_interface(
"SelectProps",
"deprecated/components/Select/Select.d.ts",
&[
"children",
"className",
"isOpen",
"isPlain",
"onSelect",
"isDisabled",
"isGrouped",
"isCreatable",
"selections",
"onToggle",
"direction",
"position",
"toggleRef",
"width",
"zIndex",
"variant",
],
);
let old_main_select = make_interface(
"SelectProps",
"components/Select/Select.d.ts",
&[
"children",
"className",
"isOpen",
"isPlain",
"onSelect",
"direction",
"position",
"selected",
"toggle",
"toggleRef",
"width",
"zIndex",
"onOpenChange",
"innerRef",
"isScrollable",
],
);
let new_main_select = make_interface(
"SelectProps",
"components/Select/Select.d.ts",
&[
"children",
"className",
"isOpen",
"isPlain",
"onSelect",
"direction",
"position",
"selected",
"toggle",
"toggleRef",
"width",
"zIndex",
"onOpenChange",
"innerRef",
"isScrollable",
"variant",
"focusTimeoutDelay",
"onToggleKeydown",
"shouldPreventScrollOnItemFocus",
],
);
let old_symbols: Vec<&Symbol> = vec![&old_select, &old_main_select];
let new_symbols: Vec<&Symbol> = vec![&new_main_select];
let removed: Vec<&Symbol> = vec![&old_select];
let results = detect_migrations(
&removed,
&old_symbols,
&new_symbols,
&MinimalSemantics,
&HashMap::<String, Vec<String>>::new(),
);
assert_eq!(results.len(), 0);
}
#[test]
fn test_decompose_into_children_modal_pattern() {
let old_modal = make_interface(
"ModalProps",
"components/Modal/Modal.d.ts",
&[
"children",
"className",
"isOpen",
"variant",
"title",
"actions",
"description",
"footer",
"header",
"help",
"titleIconVariant",
"onClose",
"showClose",
],
);
let new_modal = make_interface(
"ModalProps",
"components/Modal/Modal.d.ts",
&["children", "className", "isOpen", "variant", "onClose"],
);
let new_header = make_interface(
"ModalHeaderProps",
"components/Modal/ModalHeader.d.ts",
&[
"children",
"className",
"title",
"description",
"help",
"titleIconVariant",
"labelId",
"descriptorId",
"titleScreenReaderText",
],
);
let old_symbols: Vec<&Symbol> = vec![&old_modal];
let new_symbols: Vec<&Symbol> = vec![&new_modal, &new_header];
let removed: Vec<&Symbol> = vec![];
let results = detect_migrations(
&removed,
&old_symbols,
&new_symbols,
&MinimalSemantics,
&HashMap::<String, Vec<String>>::new(),
);
assert_eq!(
results.len(),
0,
"No removed interfaces => no migration suggestions"
);
}
#[test]
fn test_no_false_positive_unrelated_interfaces() {
let removed_foo = make_interface(
"FooProps",
"components/Foo/Foo.d.ts",
&["children", "className", "onClick"],
);
let new_bar = make_interface(
"BarProps",
"components/Bar/Bar.d.ts",
&["children", "className", "onSubmit"],
);
let old_symbols: Vec<&Symbol> = vec![&removed_foo];
let new_symbols: Vec<&Symbol> = vec![&new_bar];
let removed: Vec<&Symbol> = vec![&removed_foo];
let results = detect_migrations(
&removed,
&old_symbols,
&new_symbols,
&MinimalSemantics,
&HashMap::<String, Vec<String>>::new(),
);
assert_eq!(results.len(), 0, "Different directories should not match");
}
#[test]
fn test_small_overlap_with_adaptive_threshold() {
let removed_header = make_interface(
"FooHeaderProps",
"components/Foo/FooHeader.d.ts",
&["title", "subtitle"],
);
let new_foo = make_interface(
"FooProps",
"components/Foo/Foo.d.ts",
&[
"children",
"className",
"title",
"variant",
"size",
"isOpen",
"onClose",
"isFullscreen",
],
);
let old_symbols: Vec<&Symbol> = vec![&removed_header];
let new_symbols: Vec<&Symbol> = vec![&new_foo];
let removed: Vec<&Symbol> = vec![&removed_header];
let results = detect_migrations(
&removed,
&old_symbols,
&new_symbols,
&MinimalSemantics,
&HashMap::<String, Vec<String>>::new(),
);
assert_eq!(
results.len(),
1,
"Small interface with 50% match should be detected as absorption"
);
assert_eq!(results[0].target.removed_symbol, "FooHeaderProps");
assert_eq!(results[0].target.replacement_symbol, "FooProps");
}
#[test]
fn test_small_interface_absorption_emptystate_icon() {
let old_icon_props = make_interface(
"EmptyStateIconProps",
"components/EmptyState/EmptyStateIcon.d.ts",
&["icon", "className"],
);
let old_parent = make_interface(
"EmptyStateProps",
"components/EmptyState/EmptyState.d.ts",
&["children", "className", "variant"],
);
let new_parent = make_interface(
"EmptyStateProps",
"components/EmptyState/EmptyState.d.ts",
&[
"children",
"className",
"variant",
"icon", "titleText",
"headingLevel",
"status",
],
);
let old_symbols: Vec<&Symbol> = vec![&old_icon_props, &old_parent];
let new_symbols: Vec<&Symbol> = vec![&new_parent];
let removed: Vec<&Symbol> = vec![&old_icon_props];
let results = detect_migrations(
&removed,
&old_symbols,
&new_symbols,
&MinimalSemantics,
&HashMap::<String, Vec<String>>::new(),
);
assert_eq!(
results.len(),
1,
"Should detect EmptyStateIconProps → EmptyStateProps absorption. Got {} results.",
results.len()
);
let m = &results[0];
assert_eq!(m.target.removed_symbol, "EmptyStateIconProps");
assert_eq!(m.target.replacement_symbol, "EmptyStateProps");
let matched_names: Vec<&str> = m
.target
.matching_members
.iter()
.map(|mm| mm.old_name.as_str())
.collect();
assert!(
matched_names.contains(&"icon"),
"Should match 'icon'. Matched: {:?}",
matched_names
);
assert!(
m.target.overlap_ratio >= 0.25,
"Overlap ratio should be >= 25%, got {}",
m.target.overlap_ratio
);
}
#[test]
fn test_adaptive_threshold_values() {
assert_eq!(min_overlap_count(0), 1);
assert_eq!(min_overlap_count(1), 1);
assert_eq!(min_overlap_count(2), 1);
assert_eq!(min_overlap_count(3), 1);
assert_eq!(min_overlap_count(4), 2);
assert_eq!(min_overlap_count(5), 2);
assert_eq!(min_overlap_count(6), 2);
assert_eq!(min_overlap_count(7), 3);
assert_eq!(min_overlap_count(10), 3);
assert_eq!(min_overlap_count(50), 3);
}
#[test]
fn test_single_member_interface_no_false_positive() {
let old_tiny = make_interface("TinyProps", "components/Foo/Tiny.d.ts", &["uniqueProp"]);
let old_parent = make_interface(
"FooProps",
"components/Foo/Foo.d.ts",
&["children", "className"],
);
let new_parent = make_interface(
"FooProps",
"components/Foo/Foo.d.ts",
&["children", "className", "newProp"], );
let old_symbols: Vec<&Symbol> = vec![&old_tiny, &old_parent];
let new_symbols: Vec<&Symbol> = vec![&new_parent];
let removed: Vec<&Symbol> = vec![&old_tiny];
let results = detect_migrations(
&removed,
&old_symbols,
&new_symbols,
&MinimalSemantics,
&HashMap::<String, Vec<String>>::new(),
);
assert!(
results.is_empty(),
"Should NOT produce migration for non-matching single-member interface"
);
}
#[test]
fn test_cross_directory_migration_via_rename_chain() {
let old_text_props = make_interface(
"TextProps",
"components/Text/Text.d.ts",
&[
"component",
"children",
"className",
"isVisitedLink",
"ouiaId",
"ouiaSafe",
],
);
let new_content_props = make_interface(
"ContentProps",
"components/Content/Content.d.ts",
&[
"component",
"children",
"className",
"isVisitedLink",
"ouiaId",
"ouiaSafe",
"isEditorial",
],
);
let old_symbols: Vec<&Symbol> = vec![&old_text_props];
let new_symbols: Vec<&Symbol> = vec![&new_content_props];
let removed: Vec<&Symbol> = vec![&old_text_props];
let results = detect_migrations(
&removed,
&old_symbols,
&new_symbols,
&MinimalSemantics,
&HashMap::<String, Vec<String>>::new(),
);
assert!(
results.is_empty(),
"Without dir_renames, cross-directory should NOT match"
);
let mut dir_renames = HashMap::new();
dir_renames.insert(
"components/Text".to_string(),
vec!["components/Content".to_string()],
);
let results = detect_migrations(
&removed,
&old_symbols,
&new_symbols,
&MinimalSemantics,
&dir_renames,
);
assert_eq!(
results.len(),
1,
"Should find TextProps -> ContentProps via dir_renames"
);
assert_eq!(results[0].target.replacement_symbol, "ContentProps");
assert_eq!(results[0].target.matching_members.len(), 6);
assert!(results[0].target.removed_only_members.is_empty());
assert!(results[0].target.overlap_ratio > 0.85);
}
}