use crate::source_profile::bem::{classify_bem_relationship, BemRelationship};
use semver_analyzer_core::types::sd::{
ChildRelationship, ComponentSourceProfile, CompositionEdge, CompositionTree,
};
use std::collections::{HashMap, HashSet};
use tracing::debug;
pub fn build_composition_tree(
profiles: &HashMap<String, ComponentSourceProfile>,
family_exports: &[String],
) -> Option<CompositionTree> {
if family_exports.is_empty() {
return None;
}
let root = family_exports[0].clone();
let mut tree = CompositionTree {
root: root.clone(),
family_members: family_exports.to_vec(),
edges: Vec::new(),
};
let family_set: HashSet<&str> = family_exports.iter().map(|s| s.as_str()).collect();
for parent_name in family_exports {
let Some(parent_profile) = profiles.get(parent_name) else {
continue;
};
let internal_children: Vec<&str> = parent_profile
.rendered_components
.iter()
.filter(|c| family_set.contains(c.as_str()))
.map(|s| s.as_str())
.collect();
for child_name in &internal_children {
let child_profile = profiles.get(*child_name);
let (relationship, bem_evidence) = if let Some(child_prof) = child_profile {
classify_child_relationship(parent_profile, child_prof)
} else {
(ChildRelationship::Unknown, None)
};
tree.edges.push(CompositionEdge {
parent: parent_name.clone(),
child: child_name.to_string(),
relationship: relationship.clone(),
required: relationship == ChildRelationship::BemElement,
bem_evidence,
});
}
if parent_profile.has_children_prop {
for sibling_name in family_exports {
if sibling_name == parent_name {
continue;
}
if internal_children.contains(&sibling_name.as_str()) {
continue;
}
if is_transitively_internal(sibling_name, parent_name, profiles, &family_set) {
continue;
}
let child_profile = profiles.get(sibling_name);
let (relationship, bem_evidence) = if let Some(child_prof) = child_profile {
classify_child_relationship(parent_profile, child_prof)
} else {
(ChildRelationship::Unknown, None)
};
debug!(
parent = %parent_name,
child = %sibling_name,
?relationship,
?bem_evidence,
parent_block = ?parent_profile.bem_block,
"consumer-child BEM classification"
);
if matches!(relationship, ChildRelationship::BemElement) {
if is_prop_passed_component(parent_profile, sibling_name, parent_name) {
debug!(
parent = %parent_name,
child = %sibling_name,
"skipping BEM edge — child is prop-passed, not a JSX child"
);
continue;
}
tree.edges.push(CompositionEdge {
parent: parent_name.clone(),
child: sibling_name.clone(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence,
});
}
}
}
}
infer_ownership_by_name_prefix(&mut tree, profiles, family_exports, &root);
infer_dom_nesting(&mut tree, profiles, family_exports);
infer_context_nesting(&mut tree, profiles, family_exports);
deduplicate_edges(&mut tree);
suppress_root_edges_with_intermediate(&mut tree);
Some(tree)
}
fn classify_child_relationship(
parent: &ComponentSourceProfile,
child: &ComponentSourceProfile,
) -> (ChildRelationship, Option<String>) {
let parent_block = match &parent.bem_block {
Some(b) => b.as_str(),
None => {
if parent.rendered_components.contains(&child.name) {
return (ChildRelationship::Internal, None);
}
return (ChildRelationship::Unknown, None);
}
};
let parent_name_lower = parent.name.to_lowercase();
let block_lower = parent_block.to_lowercase();
let parent_is_block_owner = block_lower.starts_with(&parent_name_lower);
if !parent_is_block_owner {
if parent.rendered_components.contains(&child.name) {
return (ChildRelationship::Internal, None);
}
return (ChildRelationship::Unknown, None);
}
let child_raw_tokens = &child
.css_tokens_used
.iter()
.filter(|t| t.starts_with("styles.") && !t.contains("modifiers"))
.map(|t| t.strip_prefix("styles.").unwrap_or(t).to_string())
.collect();
let bem_rel =
classify_bem_relationship(child.bem_block.as_deref(), child_raw_tokens, parent_block);
match bem_rel {
BemRelationship::Element { element_name } => {
let evidence = format!(
"{} is BEM element '{}' of {} block",
child.name, element_name, parent_block
);
(ChildRelationship::BemElement, Some(evidence))
}
BemRelationship::Independent { block_name } => {
let evidence = format!("{} has independent BEM block '{}'", child.name, block_name);
(ChildRelationship::IndependentBlock, Some(evidence))
}
BemRelationship::Unknown => {
if parent.rendered_components.contains(&child.name) {
(ChildRelationship::Internal, None)
} else {
(ChildRelationship::Unknown, None)
}
}
}
}
fn is_prop_passed_component(
parent_profile: &ComponentSourceProfile,
child_name: &str,
parent_name: &str,
) -> bool {
let suffix = if let Some(stripped) = child_name.strip_prefix(parent_name) {
stripped
} else {
return false;
};
if suffix.is_empty() {
return false;
}
let suffix_lower = suffix.to_lowercase();
for (prop_name, prop_type) in &parent_profile.prop_types {
if prop_name == "children" || prop_name == "className" {
continue;
}
if !is_react_renderable_type(prop_type) {
continue;
}
let prop_lower = prop_name.to_lowercase();
if suffix_lower.starts_with(&prop_lower) || prop_lower.starts_with(&suffix_lower) {
return true;
}
}
false
}
fn is_react_renderable_type(type_str: &str) -> bool {
let t = type_str.trim();
t.contains("ReactNode")
|| t.contains("ReactElement")
|| t.contains("ComponentType")
|| t.contains("JSX.Element")
}
fn is_transitively_internal(
target: &str,
parent: &str,
profiles: &HashMap<String, ComponentSourceProfile>,
family_set: &HashSet<&str>,
) -> bool {
let Some(parent_profile) = profiles.get(parent) else {
return false;
};
for internal_comp in &parent_profile.rendered_components {
if !family_set.contains(internal_comp.as_str()) {
continue;
}
if let Some(internal_profile) = profiles.get(internal_comp.as_str()) {
if internal_profile
.rendered_components
.iter()
.any(|c| c == target)
{
return true;
}
}
}
false
}
fn infer_ownership_by_name_prefix(
tree: &mut CompositionTree,
profiles: &HashMap<String, ComponentSourceProfile>,
family_exports: &[String],
root: &str,
) {
let _root_profile = match profiles.get(root) {
Some(p) => p,
None => return,
};
let root_name_lower = root.to_lowercase();
let has_bem_edges = tree.edges.iter().any(|e| {
e.parent == root
&& matches!(
e.relationship,
ChildRelationship::DirectChild | ChildRelationship::BemElement
)
});
if has_bem_edges {
return;
}
let mut block_counts: HashMap<&str, usize> = HashMap::new();
for name in family_exports {
if name == root {
continue;
}
if let Some(profile) = profiles.get(name) {
if let Some(ref block) = profile.bem_block {
*block_counts.entry(block.as_str()).or_default() += 1;
}
}
}
let dominant_block = block_counts
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(block, _)| block);
let Some(child_block) = dominant_block else {
return;
};
if !child_block.to_lowercase().starts_with(&root_name_lower) {
return;
}
let existing: HashSet<(String, String)> = tree
.edges
.iter()
.map(|e| (e.parent.clone(), e.child.clone()))
.collect();
for child_name in family_exports {
if child_name == root {
continue;
}
if existing.contains(&(root.to_string(), child_name.clone())) {
continue;
}
let Some(child_profile) = profiles.get(child_name) else {
continue;
};
let is_element = child_profile.css_tokens_used.iter().any(|t| {
if let Some(token) = t.strip_prefix("styles.") {
token.starts_with(child_block)
&& token.len() > child_block.len()
&& token[child_block.len()..].starts_with(|c: char| c.is_uppercase())
} else {
false
}
});
if is_element {
tree.edges.push(CompositionEdge {
parent: root.to_string(),
child: child_name.clone(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence: Some(format!(
"{} name is prefix of {} block, {} uses {} tokens",
root, child_block, child_name, child_block
)),
});
}
}
}
fn infer_dom_nesting(
tree: &mut CompositionTree,
profiles: &HashMap<String, ComponentSourceProfile>,
family_exports: &[String],
) {
let existing: HashSet<(String, String)> = tree
.edges
.iter()
.map(|e| (e.parent.clone(), e.child.clone()))
.collect();
let mut new_edges = Vec::new();
for parent_name in family_exports {
let Some(parent_profile) = profiles.get(parent_name) else {
continue;
};
if !parent_profile.has_children_prop {
continue;
}
let slot_element = parent_profile
.children_slot_path
.iter()
.rev()
.find(|e| e.starts_with(|c: char| c.is_lowercase()));
let Some(slot_el) = slot_element else {
continue;
};
let expected_children = html_expected_children(slot_el);
if expected_children.is_empty() {
continue;
}
for child_name in family_exports {
if child_name == parent_name {
continue;
}
if existing.contains(&(parent_name.clone(), child_name.clone())) {
continue;
}
let Some(child_profile) = profiles.get(child_name) else {
continue;
};
let child_root = child_profile
.children_slot_path
.first()
.filter(|e| e.starts_with(|c: char| c.is_lowercase()))
.cloned()
.or_else(|| infer_root_element(child_profile));
if let Some(ref root_el) = child_root {
if expected_children.contains(&root_el.as_str()) {
new_edges.push(CompositionEdge {
parent: parent_name.clone(),
child: child_name.clone(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence: Some(format!(
"DOM nesting: {} wraps children in <{}>, {} renders <{}> as root",
parent_name, slot_el, child_name, root_el
)),
});
}
}
}
}
tree.edges.extend(new_edges);
let flow_containers = [
"section", "div", "article", "aside", "main", "nav", "header", "footer",
];
let list_tags = ["ul", "ol"];
let root_children: Vec<String> = tree
.edges
.iter()
.filter(|e| e.parent == tree.root)
.map(|e| e.child.clone())
.collect();
let mut flow_edges = Vec::new();
for child_name in &root_children {
if child_name == &tree.root {
continue;
}
let has_other_parent = tree
.edges
.iter()
.any(|e| e.child == *child_name && e.parent != tree.root);
if has_other_parent {
continue;
}
let child_profile = match profiles.get(child_name) {
Some(p) => p,
None => continue,
};
let child_root = child_profile
.children_slot_path
.first()
.filter(|e| e.starts_with(|c: char| c.is_lowercase()))
.cloned()
.or_else(|| infer_root_element(child_profile));
let is_list = child_root
.as_ref()
.is_some_and(|r| list_tags.contains(&r.as_str()));
if !is_list {
continue;
}
for parent_name in family_exports {
if parent_name == child_name || parent_name == &tree.root {
continue;
}
let existing_edge = tree
.edges
.iter()
.any(|e| e.parent == *parent_name && e.child == *child_name);
if existing_edge {
continue;
}
let parent_profile = match profiles.get(parent_name) {
Some(p) => p,
None => continue,
};
if !parent_profile.has_children_prop {
continue;
}
let parent_slot = parent_profile
.children_slot_path
.iter()
.rev()
.find(|e| e.starts_with(|c: char| c.is_lowercase()));
let is_flow = parent_slot.is_some_and(|s| flow_containers.contains(&s.as_str()));
if !is_flow {
continue;
}
let parent_is_root_child = tree
.edges
.iter()
.any(|e| e.parent == tree.root && e.child == *parent_name);
if !parent_is_root_child {
continue;
}
flow_edges.push((parent_name.clone(), child_name.clone()));
break; }
}
for (parent, child) in flow_edges {
tree.edges
.retain(|e| !(e.parent == tree.root && e.child == child));
tree.edges.push(CompositionEdge {
parent: parent.clone(),
child: child.clone(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence: Some(format!(
"Flow container nesting: {} renders <ul>/<ol>, {} wraps {{children}} in flow container",
child, parent
)),
});
}
}
fn infer_context_nesting(
tree: &mut CompositionTree,
profiles: &HashMap<String, ComponentSourceProfile>,
family_exports: &[String],
) {
let existing: HashSet<(String, String)> = tree
.edges
.iter()
.map(|e| (e.parent.clone(), e.child.clone()))
.collect();
let mut new_edges = Vec::new();
let mut context_providers: HashMap<String, Vec<String>> = HashMap::new();
for name in family_exports {
let Some(profile) = profiles.get(name) else {
continue;
};
for rc in &profile.rendered_components {
if let Some(ctx_name) = rc.strip_suffix(".Provider") {
context_providers
.entry(ctx_name.to_string())
.or_default()
.push(name.clone());
}
}
}
if context_providers.is_empty() {
return;
}
for child_name in family_exports {
let Some(child_profile) = profiles.get(child_name) else {
continue;
};
for consumed_ctx in &child_profile.consumed_contexts {
if let Some(providers) = context_providers.get(consumed_ctx) {
for provider_name in providers {
if provider_name == child_name {
continue;
}
let Some(provider_profile) = profiles.get(provider_name) else {
continue;
};
if provider_profile.consumed_contexts.contains(consumed_ctx) {
debug!(
provider = %provider_name,
consumer = %child_name,
context = %consumed_ctx,
"skipping re-provider context nesting"
);
continue;
}
if existing.contains(&(provider_name.clone(), child_name.clone())) {
continue;
}
if new_edges.iter().any(|e: &CompositionEdge| {
e.parent == *provider_name && e.child == *child_name
}) {
continue;
}
debug!(
provider = %provider_name,
consumer = %child_name,
context = %consumed_ctx,
"context nesting inferred"
);
new_edges.push(CompositionEdge {
parent: provider_name.clone(),
child: child_name.clone(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence: Some(format!(
"Context nesting: {} provides {}, {} consumes it via useContext",
provider_name, consumed_ctx, child_name
)),
});
}
}
}
}
tree.edges.extend(new_edges);
}
fn html_expected_children(parent_tag: &str) -> Vec<&'static str> {
match parent_tag {
"ul" | "ol" => vec!["li"],
"table" => vec!["thead", "tbody", "tfoot", "tr", "caption", "colgroup"],
"thead" | "tbody" | "tfoot" => vec!["tr"],
"tr" => vec!["td", "th"],
"dl" => vec!["dt", "dd"],
"select" => vec!["option", "optgroup"],
"optgroup" => vec!["option"],
_ => vec![],
}
}
fn infer_root_element(profile: &ComponentSourceProfile) -> Option<String> {
let root_candidates = [
"li", "tr", "td", "th", "dt", "dd", "option", "section", "article", "div",
];
for candidate in &root_candidates {
if profile.rendered_elements.contains_key(*candidate) {
return Some(candidate.to_string());
}
}
for prop_name in &["component", "as"] {
if let Some(default_val) = profile.prop_defaults.get(*prop_name) {
let tag = default_val.trim_matches(|c| c == '\'' || c == '"');
if !tag.is_empty()
&& tag.starts_with(|c: char| c.is_lowercase())
&& tag.chars().all(|c| c.is_ascii_alphanumeric())
{
return Some(tag.to_string());
}
}
}
None
}
fn suppress_root_edges_with_intermediate(tree: &mut CompositionTree) {
let root = &tree.root;
let children_with_intermediate: HashSet<String> = tree
.edges
.iter()
.filter(|e| e.parent != *root && matches!(e.relationship, ChildRelationship::DirectChild))
.map(|e| e.child.clone())
.collect();
if children_with_intermediate.is_empty() {
return;
}
let before = tree.edges.len();
tree.edges.retain(|edge| {
if edge.parent != *root {
return true;
}
if !children_with_intermediate.contains(&edge.child) {
return true;
}
if matches!(edge.relationship, ChildRelationship::DirectChild) {
tracing::debug!(
root = %root,
child = %edge.child,
"suppressing root BEM edge — intermediate parent exists"
);
return false;
}
true
});
let suppressed = before - tree.edges.len();
if suppressed > 0 {
tracing::debug!(
root = %root,
suppressed,
"suppressed root→child BEM edges with intermediate parents"
);
}
}
fn deduplicate_edges(tree: &mut CompositionTree) {
let mut seen = HashSet::new();
tree.edges.retain(|edge| {
let key = (edge.parent.clone(), edge.child.clone());
seen.insert(key)
});
}
#[cfg(test)]
mod tests {
use super::*;
fn make_profile(name: &str) -> ComponentSourceProfile {
ComponentSourceProfile {
name: name.to_string(),
..Default::default()
}
}
#[test]
fn test_build_dropdown_tree_no_bem_edges() {
let mut dropdown = make_profile("Dropdown");
dropdown.has_children_prop = true;
dropdown.rendered_components = vec!["Menu".into(), "MenuContent".into(), "Popper".into()];
let mut dropdown_list = make_profile("DropdownList");
dropdown_list.has_children_prop = true;
dropdown_list.rendered_components = vec!["MenuList".into()];
let mut dropdown_item = make_profile("DropdownItem");
dropdown_item.has_children_prop = true;
dropdown_item.rendered_components = vec!["MenuItem".into()];
let mut dropdown_group = make_profile("DropdownGroup");
dropdown_group.has_children_prop = true;
dropdown_group.rendered_components = vec!["MenuGroup".into()];
let mut profiles = HashMap::new();
profiles.insert("Dropdown".into(), dropdown);
profiles.insert("DropdownList".into(), dropdown_list);
profiles.insert("DropdownItem".into(), dropdown_item);
profiles.insert("DropdownGroup".into(), dropdown_group);
let family = vec![
"Dropdown".into(),
"DropdownList".into(),
"DropdownItem".into(),
"DropdownGroup".into(),
];
let tree = build_composition_tree(&profiles, &family).unwrap();
assert_eq!(tree.root, "Dropdown");
assert_eq!(tree.family_members.len(), 4);
assert!(tree.edges.is_empty());
}
#[test]
fn test_build_modal_tree() {
let mut modal = make_profile("Modal");
modal.has_children_prop = true;
modal.rendered_components = vec!["ModalContent".into()];
modal.uses_portal = true;
let mut modal_header = make_profile("ModalHeader");
modal_header.has_children_prop = true;
modal_header
.css_tokens_used
.insert("styles.modalBoxHeader".into());
modal_header
.css_tokens_used
.insert("styles.modalBoxHeaderMain".into());
modal_header.bem_block = Some("modalBox".into());
modal_header.bem_elements.insert("header".into());
let mut modal_body = make_profile("ModalBody");
modal_body.has_children_prop = true;
modal_body
.css_tokens_used
.insert("styles.modalBoxBody".into());
modal_body.bem_block = Some("modalBox".into());
modal_body.bem_elements.insert("body".into());
let mut modal_footer = make_profile("ModalFooter");
modal_footer.has_children_prop = true;
modal_footer
.css_tokens_used
.insert("styles.modalBoxFooter".into());
modal_footer.bem_block = Some("modalBox".into());
modal_footer.bem_elements.insert("footer".into());
let mut profiles = HashMap::new();
profiles.insert("Modal".into(), modal);
profiles.insert("ModalHeader".into(), modal_header);
profiles.insert("ModalBody".into(), modal_body);
profiles.insert("ModalFooter".into(), modal_footer);
let family = vec![
"Modal".into(),
"ModalHeader".into(),
"ModalBody".into(),
"ModalFooter".into(),
];
let tree = build_composition_tree(&profiles, &family).unwrap();
assert_eq!(tree.root, "Modal");
assert_eq!(tree.family_members.len(), 4);
}
#[test]
fn test_only_block_owner_parents_bem_elements() {
let mut masthead = make_profile("Masthead");
masthead.has_children_prop = true;
masthead.bem_block = Some("masthead".into());
masthead.css_tokens_used.insert("styles.masthead".into());
let mut brand = make_profile("MastheadBrand");
brand.has_children_prop = true;
brand.bem_block = Some("masthead".into()); brand.css_tokens_used.insert("styles.mastheadBrand".into());
let mut content = make_profile("MastheadContent");
content.has_children_prop = true;
content.bem_block = Some("masthead".into());
content
.css_tokens_used
.insert("styles.mastheadContent".into());
let mut profiles = HashMap::new();
profiles.insert("Masthead".into(), masthead);
profiles.insert("MastheadBrand".into(), brand);
profiles.insert("MastheadContent".into(), content);
let family = vec![
"Masthead".into(),
"MastheadBrand".into(),
"MastheadContent".into(),
];
let tree = build_composition_tree(&profiles, &family).unwrap();
assert!(tree
.edges
.iter()
.any(|e| e.parent == "Masthead" && e.child == "MastheadBrand" && !e.required));
assert!(tree
.edges
.iter()
.any(|e| e.parent == "Masthead" && e.child == "MastheadContent" && !e.required));
assert!(
!tree
.edges
.iter()
.any(|e| e.parent == "MastheadBrand" && e.child == "MastheadContent"),
"Non-owner MastheadBrand should not claim MastheadContent as child"
);
assert!(
!tree
.edges
.iter()
.any(|e| e.parent == "MastheadContent" && e.child == "MastheadBrand"),
"Non-owner MastheadContent should not claim MastheadBrand as child"
);
}
#[test]
fn test_element_with_block_token_is_not_owner() {
let mut menu = make_profile("Menu");
menu.has_children_prop = true;
menu.bem_block = Some("menu".into());
menu.css_tokens_used.insert("styles.menu".into());
let mut menu_item = make_profile("MenuItem");
menu_item.has_children_prop = true;
menu_item.bem_block = Some("menu".into());
menu_item.css_tokens_used.insert("styles.menuItem".into());
menu_item
.css_tokens_used
.insert("styles.menuListItem".into());
menu_item.css_tokens_used.insert("styles.menu".into());
let mut menu_list = make_profile("MenuList");
menu_list.has_children_prop = true;
menu_list.bem_block = Some("menu".into());
menu_list.css_tokens_used.insert("styles.menuList".into());
let mut profiles = HashMap::new();
profiles.insert("Menu".into(), menu);
profiles.insert("MenuItem".into(), menu_item);
profiles.insert("MenuList".into(), menu_list);
let family = vec!["Menu".into(), "MenuItem".into(), "MenuList".into()];
let tree = build_composition_tree(&profiles, &family).unwrap();
assert!(tree
.edges
.iter()
.any(|e| e.parent == "Menu" && e.child == "MenuItem"));
assert!(tree
.edges
.iter()
.any(|e| e.parent == "Menu" && e.child == "MenuList"));
assert!(
!tree
.edges
.iter()
.any(|e| e.parent == "MenuItem" && e.child == "MenuList"),
"MenuItem should not claim MenuList — it's not the block owner"
);
assert!(
!tree
.edges
.iter()
.any(|e| e.parent == "MenuItem" && e.child == "Menu"),
"MenuItem should not claim Menu — it's not the block owner"
);
}
#[test]
fn test_context_nesting_provider_consumer() {
let mut menu = make_profile("Menu");
menu.has_children_prop = true;
menu.rendered_components = vec!["MenuContext.Provider".into()];
let mut menu_list = make_profile("MenuList");
menu_list.has_children_prop = true;
menu_list.consumed_contexts = vec!["MenuContext".into()];
let mut profiles = HashMap::new();
profiles.insert("Menu".into(), menu);
profiles.insert("MenuList".into(), menu_list);
let family = vec!["Menu".into(), "MenuList".into()];
let tree = build_composition_tree(&profiles, &family).unwrap();
let menu_to_list = tree
.edges
.iter()
.find(|e| e.parent == "Menu" && e.child == "MenuList");
assert!(
menu_to_list.is_some(),
"Expected Menu → MenuList from context nesting, got edges: {:?}",
tree.edges
);
assert!(menu_to_list
.unwrap()
.bem_evidence
.as_ref()
.unwrap()
.contains("Context nesting"));
}
#[test]
fn test_dom_nesting_ul_li() {
let mut menu_list = make_profile("MenuList");
menu_list.has_children_prop = true;
menu_list.children_slot_path = vec!["ul".into()];
menu_list.rendered_elements.insert("ul".into(), 1);
let mut menu_item = make_profile("MenuItem");
menu_item.has_children_prop = true;
menu_item.children_slot_path = vec!["li".into(), "button".into(), "span".into()];
menu_item.rendered_elements.insert("li".into(), 1);
menu_item.rendered_elements.insert("button".into(), 1);
menu_item.rendered_elements.insert("span".into(), 3);
let mut profiles = HashMap::new();
profiles.insert("MenuList".into(), menu_list);
profiles.insert("MenuItem".into(), menu_item);
let family = vec!["MenuList".into(), "MenuItem".into()];
let tree = build_composition_tree(&profiles, &family).unwrap();
let list_to_item = tree
.edges
.iter()
.find(|e| e.parent == "MenuList" && e.child == "MenuItem");
assert!(
list_to_item.is_some(),
"Expected MenuList → MenuItem from DOM nesting (ul→li), got edges: {:?}",
tree.edges
);
assert!(list_to_item
.unwrap()
.bem_evidence
.as_ref()
.unwrap()
.contains("DOM nesting"));
}
#[test]
fn test_build_menu_tree_bem_elements() {
let mut menu = make_profile("Menu");
menu.has_children_prop = true;
menu.bem_block = Some("menu".into());
menu.css_tokens_used.insert("styles.menu".into());
menu.css_tokens_used.insert("styles.divider".into());
let mut menu_list = make_profile("MenuList");
menu_list.has_children_prop = true;
menu_list.bem_block = Some("menu".into()); menu_list.css_tokens_used.insert("styles.menuList".into());
let mut menu_item = make_profile("MenuItem");
menu_item.has_children_prop = true;
menu_item.bem_block = Some("menu".into()); menu_item
.css_tokens_used
.insert("styles.menuListItem".into());
menu_item
.css_tokens_used
.insert("styles.menuItemMain".into());
let mut profiles = HashMap::new();
profiles.insert("Menu".into(), menu);
profiles.insert("MenuList".into(), menu_list);
profiles.insert("MenuItem".into(), menu_item);
let family = vec!["Menu".into(), "MenuList".into(), "MenuItem".into()];
let tree = build_composition_tree(&profiles, &family).unwrap();
let menu_to_list = tree
.edges
.iter()
.find(|e| e.parent == "Menu" && e.child == "MenuList");
assert!(
menu_to_list.is_some(),
"Expected Menu → MenuList edge, got edges: {:?}",
tree.edges
);
assert!(!menu_to_list.unwrap().required);
let menu_to_item = tree
.edges
.iter()
.find(|e| e.parent == "Menu" && e.child == "MenuItem");
assert!(
menu_to_item.is_some(),
"Expected Menu → MenuItem edge, got edges: {:?}",
tree.edges
);
assert!(
!tree
.edges
.iter()
.any(|e| e.parent == "MenuList" && e.child == "MenuItem"),
"MenuList is not the block owner and should not claim MenuItem"
);
}
#[test]
fn test_is_prop_passed_component() {
let mut alert = make_profile("Alert");
alert
.prop_types
.insert("actionClose".into(), "React.ReactNode".into());
alert
.prop_types
.insert("actionLinks".into(), "React.ReactNode".into());
alert
.prop_types
.insert("children".into(), "React.ReactNode".into());
alert
.prop_types
.insert("variant".into(), "'success' | 'danger'".into());
assert!(
is_prop_passed_component(&alert, "AlertActionCloseButton", "Alert"),
"AlertActionCloseButton should be detected as prop-passed via actionClose"
);
assert!(
is_prop_passed_component(&alert, "AlertActionLink", "Alert"),
"AlertActionLink should be detected as prop-passed via actionLinks"
);
assert!(
!is_prop_passed_component(&alert, "AlertGroup", "Alert"),
"AlertGroup should not be detected as prop-passed"
);
assert!(
!is_prop_passed_component(&alert, "Button", "Alert"),
"Button should not match (no parent prefix)"
);
}
#[test]
fn test_suppress_root_edges_with_intermediate() {
let mut tree = CompositionTree {
root: "Accordion".into(),
family_members: vec![
"Accordion".into(),
"AccordionItem".into(),
"AccordionContent".into(),
"AccordionToggle".into(),
],
edges: vec![
CompositionEdge {
parent: "Accordion".into(),
child: "AccordionContent".into(),
relationship: ChildRelationship::DirectChild,
required: true,
bem_evidence: Some("BEM element of accordion block".into()),
},
CompositionEdge {
parent: "Accordion".into(),
child: "AccordionToggle".into(),
relationship: ChildRelationship::DirectChild,
required: true,
bem_evidence: Some("BEM element of accordion block".into()),
},
CompositionEdge {
parent: "Accordion".into(),
child: "AccordionItem".into(),
relationship: ChildRelationship::DirectChild,
required: true,
bem_evidence: Some("BEM element of accordion block".into()),
},
CompositionEdge {
parent: "AccordionItem".into(),
child: "AccordionContent".into(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence: Some("Context nesting".into()),
},
CompositionEdge {
parent: "AccordionItem".into(),
child: "AccordionToggle".into(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence: Some("Context nesting".into()),
},
],
};
suppress_root_edges_with_intermediate(&mut tree);
assert!(
tree.edges
.iter()
.any(|e| e.parent == "Accordion" && e.child == "AccordionItem"),
"Accordion → AccordionItem should be kept"
);
assert!(
!tree
.edges
.iter()
.any(|e| e.parent == "Accordion" && e.child == "AccordionContent"),
"Accordion → AccordionContent should be suppressed (intermediate exists)"
);
assert!(
!tree
.edges
.iter()
.any(|e| e.parent == "Accordion" && e.child == "AccordionToggle"),
"Accordion → AccordionToggle should be suppressed (intermediate exists)"
);
assert!(
tree.edges
.iter()
.any(|e| e.parent == "AccordionItem" && e.child == "AccordionContent"),
"AccordionItem → AccordionContent should be kept"
);
assert!(
tree.edges
.iter()
.any(|e| e.parent == "AccordionItem" && e.child == "AccordionToggle"),
"AccordionItem → AccordionToggle should be kept"
);
assert_eq!(tree.edges.len(), 3, "Should have 3 edges remaining");
}
#[test]
fn test_suppress_no_false_positives_masthead() {
let mut tree = CompositionTree {
root: "Masthead".into(),
family_members: vec![
"Masthead".into(),
"MastheadBrand".into(),
"MastheadContent".into(),
"MastheadMain".into(),
],
edges: vec![
CompositionEdge {
parent: "Masthead".into(),
child: "MastheadBrand".into(),
relationship: ChildRelationship::DirectChild,
required: true,
bem_evidence: None,
},
CompositionEdge {
parent: "Masthead".into(),
child: "MastheadContent".into(),
relationship: ChildRelationship::DirectChild,
required: true,
bem_evidence: None,
},
CompositionEdge {
parent: "Masthead".into(),
child: "MastheadMain".into(),
relationship: ChildRelationship::DirectChild,
required: true,
bem_evidence: None,
},
],
};
suppress_root_edges_with_intermediate(&mut tree);
assert_eq!(
tree.edges.len(),
3,
"No edges should be suppressed for Masthead"
);
}
#[test]
fn test_full_tree_build_suppresses_root_bem_when_context_intermediate() {
let mut accordion = make_profile("Accordion");
accordion.has_children_prop = true;
accordion.bem_block = Some("accordion".into());
accordion.css_tokens_used.insert("styles.accordion".into());
let mut item = make_profile("AccordionItem");
item.has_children_prop = true;
item.bem_block = Some("accordion".into());
item.css_tokens_used.insert("styles.accordionItem".into());
item.rendered_components
.push("AccordionItemContext.Provider".into());
let mut content = make_profile("AccordionContent");
content.has_children_prop = true;
content.bem_block = Some("accordion".into());
content
.css_tokens_used
.insert("styles.accordionExpandableContent".into());
content
.consumed_contexts
.push("AccordionItemContext".into());
let mut toggle = make_profile("AccordionToggle");
toggle.has_children_prop = true;
toggle.bem_block = Some("accordion".into());
toggle
.css_tokens_used
.insert("styles.accordionToggle".into());
toggle.consumed_contexts.push("AccordionItemContext".into());
let mut profiles = HashMap::new();
profiles.insert("Accordion".into(), accordion);
profiles.insert("AccordionItem".into(), item);
profiles.insert("AccordionContent".into(), content);
profiles.insert("AccordionToggle".into(), toggle);
let family = vec![
"Accordion".into(),
"AccordionItem".into(),
"AccordionContent".into(),
"AccordionToggle".into(),
];
let tree = build_composition_tree(&profiles, &family).unwrap();
assert!(
tree.edges
.iter()
.any(|e| e.parent == "Accordion" && e.child == "AccordionItem"),
"Accordion → AccordionItem should exist"
);
assert!(
tree.edges
.iter()
.any(|e| e.parent == "AccordionItem" && e.child == "AccordionContent"),
"AccordionItem → AccordionContent should exist (context nesting)"
);
assert!(
!tree
.edges
.iter()
.any(|e| e.parent == "Accordion" && e.child == "AccordionContent"),
"Accordion → AccordionContent should be suppressed (intermediate exists)"
);
assert!(
tree.edges
.iter()
.any(|e| e.parent == "AccordionItem" && e.child == "AccordionToggle"),
"AccordionItem → AccordionToggle should exist (context nesting)"
);
assert!(
!tree
.edges
.iter()
.any(|e| e.parent == "Accordion" && e.child == "AccordionToggle"),
"Accordion → AccordionToggle should be suppressed (intermediate exists)"
);
}
#[test]
fn test_full_tree_build_skips_prop_passed_bem_component() {
let mut alert = make_profile("Alert");
alert.has_children_prop = true;
alert.bem_block = Some("alert".into());
alert.css_tokens_used.insert("styles.alert".into());
alert
.prop_types
.insert("actionClose".into(), "React.ReactNode".into());
alert
.prop_types
.insert("children".into(), "React.ReactNode".into());
let mut close_btn = make_profile("AlertActionCloseButton");
close_btn.bem_block = Some("alert".into());
close_btn
.css_tokens_used
.insert("styles.alertActionClose".into());
let mut body = make_profile("AlertBody");
body.bem_block = Some("alert".into());
body.css_tokens_used.insert("styles.alertBody".into());
let mut profiles = HashMap::new();
profiles.insert("Alert".into(), alert);
profiles.insert("AlertActionCloseButton".into(), close_btn);
profiles.insert("AlertBody".into(), body);
let family = vec![
"Alert".into(),
"AlertActionCloseButton".into(),
"AlertBody".into(),
];
let tree = build_composition_tree(&profiles, &family).unwrap();
assert!(
!tree
.edges
.iter()
.any(|e| e.parent == "Alert" && e.child == "AlertActionCloseButton"),
"AlertActionCloseButton should be skipped — it's prop-passed via actionClose"
);
assert!(
tree.edges
.iter()
.any(|e| e.parent == "Alert" && e.child == "AlertBody"),
"AlertBody should be a child of Alert (regular BEM element)"
);
}
#[test]
fn test_bem_edges_are_not_required() {
let mut parent = make_profile("Dropdown");
parent.has_children_prop = true;
parent.bem_block = Some("dropdown".into());
parent.css_tokens_used.insert("styles.dropdown".into());
let mut list = make_profile("DropdownList");
list.has_children_prop = true;
list.bem_block = Some("dropdown".into());
list.css_tokens_used.insert("styles.dropdownList".into());
let mut group = make_profile("DropdownGroup");
group.has_children_prop = true;
group.bem_block = Some("dropdown".into());
group.css_tokens_used.insert("styles.dropdownGroup".into());
let mut profiles = HashMap::new();
profiles.insert("Dropdown".into(), parent);
profiles.insert("DropdownList".into(), list);
profiles.insert("DropdownGroup".into(), group);
let family = vec![
"Dropdown".into(),
"DropdownList".into(),
"DropdownGroup".into(),
];
let tree = build_composition_tree(&profiles, &family).unwrap();
for edge in &tree.edges {
if edge.relationship == ChildRelationship::DirectChild {
assert!(
!edge.required,
"BEM edge {} → {} should not be required (BEM proves membership, not requirement)",
edge.parent, edge.child
);
}
}
}
}