use crate::composition::build_composition_tree;
use crate::source_profile::{self, diff::diff_profiles};
use semver_analyzer_core::types::sd::{
ComponentSourceProfile, CompositionChange, CompositionChangeType, CompositionTree,
ConformanceCheck, ConformanceCheckType, SdPipelineResult,
};
use anyhow::Result;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::Path;
use std::process::Command;
use tracing::{debug, info, info_span, warn};
pub fn run_sd(
repo: &Path,
from_ref: &str,
to_ref: &str,
css_profiles: Option<&HashMap<String, crate::css_profile::CssBlockProfile>>,
) -> Result<SdPipelineResult> {
let _span = info_span!("sd_pipeline", %from_ref, %to_ref).entered();
let changed_files = find_changed_component_files(repo, from_ref, to_ref)?;
info!(count = changed_files.len(), "changed component files found");
let mut old_profiles: HashMap<String, ComponentSourceProfile> = HashMap::new();
let mut all_source_changes = Vec::new();
for file_info in &changed_files {
let old_source = read_git_file(repo, from_ref, &file_info.path);
let new_source = read_git_file(repo, to_ref, &file_info.path);
if let Some(ref source) = old_source {
let profile =
source_profile::extract_profile(&file_info.component_name, &file_info.path, source);
let is_deprecated = file_info.path.contains("/deprecated/");
if let Some(existing) = old_profiles.get(&file_info.component_name) {
let existing_is_deprecated = existing.file.contains("/deprecated/");
if existing_is_deprecated && !is_deprecated {
old_profiles.insert(file_info.component_name.clone(), profile);
}
} else {
old_profiles.insert(file_info.component_name.clone(), profile);
}
}
if let (Some(old_src), Some(new_src)) = (&old_source, &new_source) {
let old_p = source_profile::extract_profile(
&file_info.component_name,
&file_info.path,
old_src,
);
let new_p = source_profile::extract_profile(
&file_info.component_name,
&file_info.path,
new_src,
);
let changes = diff_profiles(&old_p, &new_p);
if !changes.is_empty() {
debug!(
component = %file_info.component_name,
changes = changes.len(),
"source-level changes detected"
);
}
all_source_changes.extend(changes);
}
}
info!(
total_changes = all_source_changes.len(),
"Phase A complete: source-level diff"
);
let all_to_files = find_all_component_files(repo, to_ref)?;
info!(
count = all_to_files.len(),
"all component files in to-version"
);
let mut new_profiles: HashMap<String, ComponentSourceProfile> = HashMap::new();
for file_info in &all_to_files {
if let Some(source) = read_git_file(repo, to_ref, &file_info.path) {
let profile = source_profile::extract_profile(
&file_info.component_name,
&file_info.path,
&source,
);
let is_deprecated = file_info.path.contains("/deprecated/");
if let Some(existing) = new_profiles.get(&file_info.component_name) {
let existing_is_deprecated = existing.file.contains("/deprecated/");
if existing_is_deprecated && !is_deprecated {
new_profiles.insert(file_info.component_name.clone(), profile);
}
} else {
new_profiles.insert(file_info.component_name.clone(), profile);
}
}
}
info!(
new_profiles = new_profiles.len(),
"to-version profiles extracted"
);
let all_families = group_by_family(&all_to_files);
let changed_families: HashSet<String> = changed_files
.iter()
.filter_map(|f| f.family.clone())
.collect();
let mut composition_trees = Vec::new();
let mut family_exports_map: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (family_name, family_files) in &all_families {
let new_exports = read_family_exports_from_dir(repo, to_ref, family_name, family_files);
let all_member_names: Vec<String> = family_files
.iter()
.map(|f| f.component_name.clone())
.collect();
let all_family_profiles = collect_family_profiles(&new_profiles, &all_member_names);
let mut all_members_for_tree = new_exports.clone();
for name in &all_member_names {
if !all_members_for_tree.contains(name) {
all_members_for_tree.push(name.clone());
}
}
let full_tree = build_composition_tree(&all_family_profiles, &all_members_for_tree);
if let Some(mut tree) = full_tree {
let exports_set: HashSet<&str> = new_exports.iter().map(|s| s.as_str()).collect();
collapse_internal_nodes(&mut tree, &exports_set);
composition_trees.push(tree);
}
family_exports_map.insert(family_name.clone(), new_exports);
}
let trees_snapshot: Vec<CompositionTree> = composition_trees.clone();
project_delegate_trees(&mut composition_trees, &new_profiles, &trees_snapshot);
if let Some(css_profs) = css_profiles {
enrich_trees_with_css(&mut composition_trees, css_profs, &new_profiles);
}
let mut composition_changes = Vec::new();
let mut conformance_checks = Vec::new();
let mut old_composition_trees = Vec::new();
for tree in &composition_trees {
let family_name = &tree.root;
let checks = generate_conformance_checks(family_name, tree, &new_profiles);
conformance_checks.extend(checks);
if changed_families.contains(family_name) {
if let Some(family_files) = all_families.get(family_name) {
let new_exports = family_exports_map
.get(family_name)
.cloned()
.unwrap_or_default();
let old_exports =
read_family_exports_from_dir(repo, from_ref, family_name, family_files);
let old_family_profiles =
extract_family_profiles_at_ref(repo, from_ref, &old_exports, family_files);
let old_tree = build_composition_tree(&old_family_profiles, &old_exports);
let changes = diff_composition_trees(
family_name,
old_tree.as_ref(),
tree,
&old_exports,
&new_exports,
);
composition_changes.extend(changes);
if let Some(ot) = old_tree {
old_composition_trees.push(ot);
}
}
}
}
info!(
composition_trees = composition_trees.len(),
composition_changes = composition_changes.len(),
conformance_checks = conformance_checks.len(),
"Phase B complete: composition analysis"
);
let old_component_props: HashMap<String, BTreeSet<String>> = old_profiles
.iter()
.map(|(name, profile)| (name.clone(), profile.all_props.clone()))
.collect();
let new_component_props: HashMap<String, BTreeSet<String>> = new_profiles
.iter()
.map(|(name, profile)| (name.clone(), profile.all_props.clone()))
.collect();
let old_component_prop_types: HashMap<String, BTreeMap<String, String>> = old_profiles
.iter()
.filter(|(_, profile)| !profile.prop_types.is_empty())
.map(|(name, profile)| (name.clone(), profile.prop_types.clone()))
.collect();
let new_component_prop_types: HashMap<String, BTreeMap<String, String>> = new_profiles
.iter()
.filter(|(_, profile)| !profile.prop_types.is_empty())
.map(|(name, profile)| (name.clone(), profile.prop_types.clone()))
.collect();
let new_required_props: HashMap<String, BTreeSet<String>> = new_profiles
.iter()
.filter(|(_, profile)| !profile.required_props.is_empty())
.map(|(name, profile)| (name.clone(), profile.required_props.clone()))
.collect();
let old_component_packages: HashMap<String, String> = old_profiles
.iter()
.filter_map(|(name, profile)| {
resolve_component_package(&profile.file).map(|pkg| (name.clone(), pkg))
})
.collect();
let component_packages: HashMap<String, String> = new_profiles
.iter()
.filter_map(|(name, profile)| {
resolve_component_package(&profile.file).map(|pkg| (name.clone(), pkg))
})
.collect();
Ok(SdPipelineResult {
source_level_changes: all_source_changes,
composition_trees,
old_composition_trees,
composition_changes,
conformance_checks,
component_packages,
old_component_packages,
old_component_props,
new_component_props,
old_component_prop_types,
new_component_prop_types,
new_required_props,
dep_repo_packages: HashMap::new(), removed_css_blocks: Vec::new(), old_profiles,
new_profiles,
})
}
#[derive(Debug, Clone)]
struct ComponentFile {
path: String,
component_name: String,
family: Option<String>,
}
fn find_changed_component_files(
repo: &Path,
from_ref: &str,
to_ref: &str,
) -> Result<Vec<ComponentFile>> {
let output = Command::new("git")
.args([
"-C",
&repo.to_string_lossy(),
"diff",
"--name-only",
&format!("{}..{}", from_ref, to_ref),
"--",
"*.tsx",
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git diff --name-only failed: {}", stderr);
}
Ok(parse_component_file_list(&String::from_utf8_lossy(
&output.stdout,
)))
}
fn find_all_component_files(repo: &Path, git_ref: &str) -> Result<Vec<ComponentFile>> {
let output = Command::new("git")
.args([
"-C",
&repo.to_string_lossy(),
"ls-tree",
"-r",
"--name-only",
git_ref,
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(%stderr, "git ls-tree failed, falling back to empty");
return Ok(Vec::new());
}
let all_output = String::from_utf8_lossy(&output.stdout);
let tsx_only: String = all_output
.lines()
.filter(|line| line.ends_with(".tsx"))
.collect::<Vec<_>>()
.join("\n");
Ok(parse_component_file_list(&tsx_only))
}
fn parse_component_file_list(output: &str) -> Vec<ComponentFile> {
output
.lines()
.filter_map(|line| {
let path = line.trim().to_string();
if path.is_empty() || should_exclude_from_sd(&path) {
return None;
}
let component_name = extract_component_name(&path)?;
let family = extract_family_from_path(&path);
Some(ComponentFile {
path,
component_name,
family,
})
})
.collect()
}
fn should_exclude_from_sd(path: &str) -> bool {
path.contains(".test.") || path.contains(".spec.")
|| path.contains("__tests__") || path.contains("__mocks__")
|| path.ends_with("/index.tsx") || path == "index.tsx"
|| path.contains("/dist/") || path.starts_with("dist/")
|| path.ends_with(".d.ts") || path.ends_with(".d.tsx")
|| path.contains("/examples/") || path.contains("/demos/")
|| path.contains(".figma.")
}
fn extract_component_name(path: &str) -> Option<String> {
let filename = path.rsplit('/').next()?;
let stem = filename.strip_suffix(".tsx")?;
if stem.starts_with(|c: char| c.is_ascii_uppercase()) {
Some(stem.to_string())
} else {
None
}
}
fn extract_family_from_path(path: &str) -> Option<String> {
let parts: Vec<&str> = path.split('/').collect();
for (i, part) in parts.iter().enumerate() {
if *part == "components" && i + 1 < parts.len() && i + 2 < parts.len() {
return Some(parts[i + 1].to_string());
}
}
None
}
fn group_by_family(files: &[ComponentFile]) -> BTreeMap<String, Vec<&ComponentFile>> {
let mut groups: BTreeMap<String, Vec<&ComponentFile>> = BTreeMap::new();
for file in files {
if let Some(ref family) = file.family {
groups.entry(family.clone()).or_default().push(file);
}
}
groups
}
fn collect_family_profiles(
all_profiles: &HashMap<String, semver_analyzer_core::types::sd::ComponentSourceProfile>,
family_exports: &[String],
) -> HashMap<String, semver_analyzer_core::types::sd::ComponentSourceProfile> {
family_exports
.iter()
.filter_map(|name| all_profiles.get(name).map(|p| (name.clone(), p.clone())))
.collect()
}
fn extract_family_profiles_at_ref(
repo: &Path,
git_ref: &str,
family_exports: &[String],
family_files: &[&ComponentFile],
) -> HashMap<String, semver_analyzer_core::types::sd::ComponentSourceProfile> {
let mut profiles = HashMap::new();
let family_dir = family_files
.first()
.and_then(|f| f.path.rsplit_once('/').map(|(dir, _)| dir.to_string()))
.unwrap_or_default();
for component_name in family_exports {
let file_path = format!("{}/{}.tsx", family_dir, component_name);
if let Some(source) = read_git_file(repo, git_ref, &file_path) {
let profile = source_profile::extract_profile(component_name, &file_path, &source);
profiles.insert(component_name.clone(), profile);
}
}
profiles
}
fn read_family_exports_from_dir(
repo: &Path,
git_ref: &str,
family: &str,
family_files: &[&ComponentFile],
) -> Vec<String> {
let family_dir = family_files
.first()
.and_then(|f| f.path.rsplit_once('/').map(|(dir, _)| dir.to_string()))
.unwrap_or_default();
for index_name in &["index.ts", "index.tsx"] {
let index_path = format!("{}/{}", family_dir, index_name);
if let Some(content) = read_git_file(repo, git_ref, &index_path) {
let exports = parse_index_exports(&content, family);
if !exports.is_empty() {
return exports;
}
}
}
let mut names: Vec<String> = family_files
.iter()
.map(|f| f.component_name.clone())
.collect();
if let Some(pos) = names.iter().position(|n| n == family) {
names.swap(0, pos);
}
names
}
fn parse_index_exports(content: &str, family: &str) -> Vec<String> {
let mut exports = Vec::new();
let mut seen = HashSet::new();
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("export") {
continue;
}
if trimmed.starts_with("export *") || trimmed.starts_with("export type *") {
if let Some(path) = extract_from_path(trimmed) {
let name = path.strip_prefix("./").unwrap_or(&path).to_string();
if name.starts_with(|c: char| c.is_ascii_uppercase()) && seen.insert(name.clone()) {
exports.push(name);
}
}
continue;
}
if let Some(brace_start) = trimmed.find('{') {
if let Some(brace_end) = trimmed.find('}') {
let names_str = &trimmed[brace_start + 1..brace_end];
for part in names_str.split(',') {
let part = part.trim();
let name = if let Some((_before, after)) = part.split_once(" as ") {
after.trim().to_string()
} else {
part.to_string()
};
if name.starts_with(|c: char| c.is_ascii_uppercase())
&& !name.ends_with("Props")
&& seen.insert(name.clone())
{
exports.push(name);
}
}
}
}
}
if let Some(pos) = exports.iter().position(|n| n == family) {
exports.swap(0, pos);
}
exports
}
fn extract_from_path(line: &str) -> Option<String> {
let from_idx = line.find("from ")?;
let after_from = &line[from_idx + 5..];
let quote = after_from.chars().next()?;
if quote != '\'' && quote != '"' {
return None;
}
let end = after_from[1..].find(quote)?;
Some(after_from[1..1 + end].to_string())
}
fn enrich_trees_with_css(
trees: &mut [CompositionTree],
css_profiles: &HashMap<String, crate::css_profile::CssBlockProfile>,
react_profiles: &HashMap<String, semver_analyzer_core::types::sd::ComponentSourceProfile>,
) {
for tree in trees.iter_mut() {
let css_profile = find_matching_css_profile(tree, react_profiles, css_profiles);
let Some(css_prof) = css_profile else {
continue;
};
debug!(
family = %tree.root,
css_block = %css_prof.block,
elements = css_prof.elements.len(),
"enriching tree with CSS grid layout"
);
let root_lower = tree.root.to_lowercase();
let member_to_element: HashMap<&str, String> = tree
.family_members
.iter()
.filter_map(|name| {
let lower = name.to_lowercase();
if lower == root_lower {
return None; }
let suffix = lower.strip_prefix(&root_lower)?;
Some((name.as_str(), suffix.to_string()))
})
.collect();
let element_to_member: HashMap<&str, &str> = member_to_element
.iter()
.map(|(member, element)| (element.as_str(), *member))
.collect();
let lookup_css_el = |element: &str| -> Option<&crate::css_profile::CssElementInfo> {
css_prof.elements.get(element).or_else(|| {
css_prof
.elements
.iter()
.find(|(k, _)| k.replace('-', "") == element)
.map(|(_, v)| v)
})
};
let mut stable_grid: HashSet<&str> = HashSet::new();
let mut promoted_grid: HashSet<&str> = HashSet::new();
let mut non_grid: HashSet<&str> = HashSet::new();
for (member, element) in &member_to_element {
if let Some(info) = lookup_css_el(element) {
if info.has_grid_column {
if info.grid_column_reverts {
promoted_grid.insert(member);
} else {
stable_grid.insert(member);
}
} else {
non_grid.insert(member);
}
}
}
if stable_grid.is_empty() && promoted_grid.is_empty() && non_grid.is_empty() {
continue;
}
let mode_switcher: Option<&str> = member_to_element
.iter()
.find(|(_, element)| {
lookup_css_el(element).is_some_and(|info| {
info.is_mode_switcher
|| (info.display_values.contains("var") && info.has_grid_column)
})
})
.map(|(member, _)| *member);
debug!(
family = %tree.root,
stable_grid = ?stable_grid,
promoted_grid = ?promoted_grid,
non_grid = ?non_grid,
mode_switcher = ?mode_switcher,
"CSS grid classification"
);
let css_siblings: HashSet<(String, String)> = css_prof
.sibling_relationships
.iter()
.flat_map(|(a, b)| {
let a_norm = a.replace('-', "");
let b_norm = b.replace('-', "");
vec![(a_norm.clone(), b_norm.clone()), (b_norm, a_norm)]
})
.collect();
let root_level_children: HashSet<String> = css_prof
.has_containment
.iter()
.filter(|(parent, _)| parent.is_empty())
.map(|(_, child)| child.replace('-', ""))
.collect();
let are_css_siblings = |parent_member: &str, child_member: &str| -> bool {
let parent_el = member_to_element.get(parent_member);
let child_el = member_to_element.get(child_member);
if let (Some(p), Some(c)) = (parent_el, child_el) {
css_siblings.contains(&(p.clone(), c.clone()))
} else {
false
}
};
let is_root_level_child = |member: &str| -> bool {
member_to_element
.get(member)
.is_some_and(|el| root_level_children.contains(el))
};
if let Some(switcher) = mode_switcher {
for &member in &promoted_grid {
if member == switcher {
continue; }
tree.edges
.retain(|e| !(e.parent == tree.root && e.child == member));
if !tree
.edges
.iter()
.any(|e| e.parent == switcher && e.child == member)
{
tree.edges.push(semver_analyzer_core::types::sd::CompositionEdge {
parent: switcher.to_string(),
child: member.to_string(),
relationship: semver_analyzer_core::types::sd::ChildRelationship::DirectChild,
required: false,
bem_evidence: Some(format!(
"CSS grid nesting: {} grid-column reverts in some mode → inside {} (mode-switcher)",
member, switcher
)),
});
}
}
}
if let Some(switcher) = mode_switcher {
let switcher_element = &member_to_element[switcher];
if let Some(info) = lookup_css_el(switcher_element) {
for child_ref in &info.variable_child_refs {
let child_member = element_to_member.get(child_ref.as_str()).or_else(|| {
let no_hyphen = child_ref.replace('-', "");
element_to_member
.iter()
.find(|(k, _)| k.replace('-', "") == no_hyphen)
.map(|(_, v)| v)
});
if let Some(child) = child_member {
if !non_grid.contains(child) {
continue;
}
tree.edges
.retain(|e| !(e.parent == tree.root && e.child == *child));
if !tree
.edges
.iter()
.any(|e| e.parent == switcher && e.child == *child)
{
tree.edges.push(semver_analyzer_core::types::sd::CompositionEdge {
parent: switcher.to_string(),
child: child.to_string(),
relationship: semver_analyzer_core::types::sd::ChildRelationship::DirectChild,
required: false,
bem_evidence: Some(format!(
"CSS grid nesting: {} (no grid-column) → {} (var ref --{}__{}--{})",
child, switcher, css_prof.block, switcher_element, child_ref
)),
});
}
}
}
}
}
if stable_grid.is_empty() && promoted_grid.is_empty() {
debug!(
family = %tree.root,
non_grid = ?non_grid,
"Skipping flex-container fallback — no grid context"
);
} else {
let unassigned: Vec<&str> = non_grid
.iter()
.filter(|member| {
!tree
.edges
.iter()
.any(|e| e.child == **member && e.parent != tree.root)
})
.copied()
.collect();
if !unassigned.is_empty() {
let all_flex_containers: Vec<(&str, &str)> = member_to_element
.iter()
.filter(|(member, element)| {
**member != tree.root
&& (mode_switcher != Some(**member))
&& lookup_css_el(element)
.is_some_and(|info| info.display_values.contains("flex"))
})
.map(|(member, element)| (*member, element.as_str()))
.collect();
for &member in &unassigned {
let member_element = &member_to_element[member];
let via_var_ref = all_flex_containers.iter().find(|(_, el)| {
lookup_css_el(el).is_some_and(|info| {
info.variable_child_refs.contains(member_element.as_str())
})
});
let container = if let Some((c, _)) = via_var_ref {
Some(*c)
} else if all_flex_containers.len() == 1 {
Some(all_flex_containers[0].0)
} else {
let child_has_sizing =
lookup_css_el(member_element).is_some_and(|info| info.has_sizing);
if child_has_sizing {
all_flex_containers
.iter()
.find(|(_, el)| {
lookup_css_el(el).is_some_and(|info| {
info.flex_shrink_zero && !info.flex_wrap
})
})
.map(|(c, _)| *c)
} else {
all_flex_containers
.iter()
.find(|(_, el)| {
lookup_css_el(el).is_some_and(|info| info.flex_wrap)
})
.map(|(c, _)| *c)
}
};
if let Some(container) = container {
if container == member {
continue;
}
if are_css_siblings(container, member) {
debug!(
family = %tree.root,
parent = %container,
child = %member,
"CSS siblings — skipping flex container nesting"
);
continue;
}
if is_root_level_child(member) {
debug!(
family = %tree.root,
parent = %container,
child = %member,
"CSS :has() proves root-level child — skipping flex container nesting"
);
continue;
}
tree.edges
.retain(|e| !(e.parent == tree.root && e.child == member));
if !tree
.edges
.iter()
.any(|e| e.parent == container && e.child == member)
{
tree.edges
.push(semver_analyzer_core::types::sd::CompositionEdge {
parent: container.to_string(),
child: member.to_string(),
relationship:
semver_analyzer_core::types::sd::ChildRelationship::DirectChild,
required: false,
bem_evidence: Some(format!(
"CSS grid nesting: {} (no grid-column) inside {} (flex container)",
member, container
)),
});
}
}
}
}
}
for (css_parent, css_child) in &css_prof.descendant_nesting {
let parent_normalized = css_parent.replace('-', "");
let child_normalized = css_child.replace('-', "");
let parent_member = element_to_member
.get(css_parent.as_str())
.or_else(|| {
element_to_member
.iter()
.find(|(k, _)| k.replace('-', "") == parent_normalized)
.map(|(_, v)| v)
})
.copied();
let child_member = element_to_member
.get(css_child.as_str())
.or_else(|| {
element_to_member
.iter()
.find(|(k, _)| k.replace('-', "") == child_normalized)
.map(|(_, v)| v)
})
.copied();
if let (Some(parent), Some(child)) = (parent_member, child_member) {
if parent == child {
continue;
}
if tree
.edges
.iter()
.any(|e| e.parent == parent && e.child == child)
{
continue;
}
debug!(
family = %tree.root,
parent = %parent,
child = %child,
css_parent = %css_parent,
css_child = %css_child,
"CSS descendant nesting: adding intermediate edge"
);
tree.edges
.retain(|e| !(e.parent == tree.root && e.child == child));
tree.edges
.push(semver_analyzer_core::types::sd::CompositionEdge {
parent: parent.to_string(),
child: child.to_string(),
relationship:
semver_analyzer_core::types::sd::ChildRelationship::DirectChild,
required: false,
bem_evidence: Some(format!(
"CSS descendant nesting: .{}__{} .{}__{}",
css_prof.block, css_parent, css_prof.block, css_child
)),
});
}
}
}
}
fn find_matching_css_profile<'a>(
tree: &CompositionTree,
react_profiles: &HashMap<String, semver_analyzer_core::types::sd::ComponentSourceProfile>,
css_profiles: &'a HashMap<String, crate::css_profile::CssBlockProfile>,
) -> Option<&'a crate::css_profile::CssBlockProfile> {
if let Some(root_profile) = react_profiles.get(&tree.root) {
if let Some(ref block) = root_profile.bem_block {
if let Some(css_prof) = css_profiles.get(block) {
return Some(css_prof);
}
}
}
let mut block_counts: HashMap<&str, usize> = HashMap::new();
for member in &tree.family_members {
if let Some(profile) = react_profiles.get(member) {
if let Some(ref block) = profile.bem_block {
*block_counts.entry(block.as_str()).or_default() += 1;
}
}
}
let dominant = block_counts
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(block, _)| block)?;
css_profiles.get(dominant)
}
fn collapse_internal_nodes(tree: &mut CompositionTree, exports: &HashSet<&str>) {
let internal_nodes: HashSet<String> = tree
.family_members
.iter()
.filter(|name| !exports.contains(name.as_str()))
.cloned()
.collect();
if internal_nodes.is_empty() {
return;
}
let mut parent_to_children: HashMap<String, Vec<String>> = HashMap::new();
for edge in &tree.edges {
parent_to_children
.entry(edge.parent.clone())
.or_default()
.push(edge.child.clone());
}
loop {
let mut new_edges = Vec::new();
let mut made_progress = false;
for internal in &internal_nodes {
let parents: Vec<String> = tree
.edges
.iter()
.filter(|e| e.child == *internal)
.map(|e| e.parent.clone())
.collect();
let children: Vec<(String, semver_analyzer_core::types::sd::CompositionEdge)> = tree
.edges
.iter()
.filter(|e| e.parent == *internal)
.map(|e| (e.child.clone(), e.clone()))
.collect();
if !parents.is_empty() && !children.is_empty() {
made_progress = true;
}
for parent in &parents {
for (child, original_edge) in &children {
if parent == child {
continue;
}
new_edges.push(semver_analyzer_core::types::sd::CompositionEdge {
parent: parent.clone(),
child: child.clone(),
relationship: original_edge.relationship.clone(),
required: original_edge.required,
bem_evidence: Some(format!(
"Collapsed through internal {}: {} → {} → {}",
internal, parent, internal, child
)),
});
}
}
}
tree.edges
.retain(|e| !internal_nodes.contains(&e.parent) && !internal_nodes.contains(&e.child));
tree.edges.extend(new_edges);
if !made_progress {
break;
}
let still_has_internal = tree
.edges
.iter()
.any(|e| internal_nodes.contains(&e.parent) || internal_nodes.contains(&e.child));
if !still_has_internal {
break;
}
}
let mut seen = HashSet::new();
tree.edges
.retain(|e| seen.insert((e.parent.clone(), e.child.clone())));
tree.family_members
.retain(|name| !internal_nodes.contains(name));
}
fn project_delegate_trees(
trees: &mut [CompositionTree],
all_profiles: &HashMap<String, semver_analyzer_core::types::sd::ComponentSourceProfile>,
all_trees: &[CompositionTree],
) {
let mut component_to_tree: HashMap<&str, usize> = HashMap::new();
for (i, tree) in all_trees.iter().enumerate() {
for member in &tree.family_members {
component_to_tree.insert(member.as_str(), i);
}
}
for tree in trees.iter_mut() {
let non_internal_edges = tree
.edges
.iter()
.filter(|e| {
e.relationship != semver_analyzer_core::types::sd::ChildRelationship::Internal
})
.count();
if non_internal_edges > 0 {
continue;
}
let mut wrapper_to_delegate: HashMap<String, String> = HashMap::new();
let mut delegate_tree_idx: Option<usize> = None;
for member in &tree.family_members {
let Some(profile) = all_profiles.get(member) else {
continue;
};
for ext in &profile.extends_props {
let delegate_name = ext.strip_suffix("Props").unwrap_or(ext).to_string();
if let Some(&tree_idx) = component_to_tree.get(delegate_name.as_str()) {
if tree.family_members.contains(&delegate_name) {
continue;
}
wrapper_to_delegate.insert(member.clone(), delegate_name);
delegate_tree_idx = Some(tree_idx);
}
}
}
if wrapper_to_delegate.is_empty() {
continue;
}
let Some(dt_idx) = delegate_tree_idx else {
continue;
};
let delegate_tree = &all_trees[dt_idx];
let mut delegate_to_wrapper: HashMap<&str, &str> = HashMap::new();
for (wrapper, delegate) in &wrapper_to_delegate {
delegate_to_wrapper.insert(delegate.as_str(), wrapper.as_str());
}
debug!(
family = %tree.root,
delegate_family = %delegate_tree.root,
mappings = ?wrapper_to_delegate,
"projecting delegate tree edges"
);
for edge in &delegate_tree.edges {
let Some(wrapper_parent) = delegate_to_wrapper.get(edge.parent.as_str()) else {
continue;
};
let Some(wrapper_child) = delegate_to_wrapper.get(edge.child.as_str()) else {
continue;
};
let already_exists = tree
.edges
.iter()
.any(|e| e.parent == *wrapper_parent && e.child == *wrapper_child);
if already_exists {
continue;
}
tree.edges
.push(semver_analyzer_core::types::sd::CompositionEdge {
parent: wrapper_parent.to_string(),
child: wrapper_child.to_string(),
relationship: edge.relationship.clone(),
required: edge.required,
bem_evidence: Some(format!(
"Projected from {} tree: {} extends {}, {} extends {}",
delegate_tree.root, wrapper_parent, edge.parent, wrapper_child, edge.child,
)),
});
}
}
}
fn diff_composition_trees(
family: &str,
old_tree: Option<&CompositionTree>,
new_tree: &CompositionTree,
old_exports: &[String],
new_exports: &[String],
) -> Vec<CompositionChange> {
let mut changes = Vec::new();
let old_exports_set: HashSet<&str> = old_exports.iter().map(|s| s.as_str()).collect();
let new_exports_set: HashSet<&str> = new_exports.iter().map(|s| s.as_str()).collect();
for name in &new_exports_set {
if !old_exports_set.contains(name) {
changes.push(CompositionChange {
family: family.to_string(),
change_type: CompositionChangeType::FamilyMemberAdded {
member: name.to_string(),
},
description: format!("{} is a new component in the {} family", name, family),
before_pattern: None,
after_pattern: None,
});
}
}
for name in &old_exports_set {
if !new_exports_set.contains(name) {
changes.push(CompositionChange {
family: family.to_string(),
change_type: CompositionChangeType::FamilyMemberRemoved {
member: name.to_string(),
},
description: format!("{} was removed from the {} family", name, family),
before_pattern: None,
after_pattern: None,
});
}
}
let old_edges = old_tree.map(|t| build_edge_map(t)).unwrap_or_default();
let new_edges = build_edge_map(new_tree);
for ((parent, child), edge) in &new_edges {
if !old_edges.contains_key(&(parent.clone(), child.clone())) {
changes.push(CompositionChange {
family: family.to_string(),
change_type: CompositionChangeType::NewRequiredChild {
parent: parent.clone(),
new_child: child.clone(),
wraps: vec![],
},
description: format!(
"{} now expects {} as a child component{}",
parent,
child,
if edge.required { " (required)" } else { "" }
),
before_pattern: None,
after_pattern: Some(format!("<{}>\n <{} />\n</{}>", parent, child, parent)),
});
}
}
changes
}
fn build_edge_map(
tree: &CompositionTree,
) -> HashMap<(String, String), &semver_analyzer_core::types::sd::CompositionEdge> {
tree.edges
.iter()
.map(|e| ((e.parent.clone(), e.child.clone()), e))
.collect()
}
fn generate_conformance_checks(
family: &str,
tree: &CompositionTree,
profiles: &HashMap<String, ComponentSourceProfile>,
) -> Vec<ConformanceCheck> {
let mut checks = Vec::new();
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 == semver_analyzer_core::types::sd::ChildRelationship::Internal {
continue;
}
if edge.required {
checks.push(ConformanceCheck {
family: family.to_string(),
check_type: ConformanceCheckType::MissingChild {
parent: edge.parent.clone(),
expected_child: edge.child.clone(),
},
description: format!(
"{} should contain a {} child component",
edge.parent, edge.child
),
correct_example: Some(format!(
"<{}>\n <{} />\n</{}>",
edge.parent, edge.child, edge.parent
)),
});
}
if let Some(grandparents) = child_to_parents.get(edge.parent.as_str()) {
for grandparent in grandparents {
checks.push(ConformanceCheck {
family: family.to_string(),
check_type: ConformanceCheckType::InvalidDirectChild {
parent: grandparent.to_string(),
child: edge.child.clone(),
expected_parent: edge.parent.clone(),
},
description: format!(
"{} should be inside {}, not directly inside {}",
edge.child, edge.parent, grandparent
),
correct_example: Some(format!(
"<{}>\n <{}>\n <{} />\n </{}>\n</{}>",
grandparent, edge.parent, edge.child, edge.parent, grandparent
)),
});
}
}
}
let root = &tree.root;
let direct_child_edges: Vec<_> = tree
.edges
.iter()
.filter(|e| {
e.parent == *root
&& e.relationship == semver_analyzer_core::types::sd::ChildRelationship::DirectChild
})
.collect();
let bem_children: Vec<&str> = direct_child_edges
.iter()
.filter(|e| {
e.bem_evidence
.as_ref()
.is_some_and(|ev| ev.contains("BEM element"))
})
.map(|e| e.child.as_str())
.collect();
let has_generic_wrapper = bem_children.iter().any(|name| {
profiles.get(*name).is_some_and(|p| {
p.has_children_prop
&& p.children_slot_path
.first()
.is_some_and(|tag| matches!(tag.as_str(), "div" | "span"))
})
});
if has_generic_wrapper && !bem_children.is_empty() {
let mut allowed: Vec<String> = bem_children.iter().map(|s| s.to_string()).collect();
for edge in &tree.edges {
if edge.relationship == semver_analyzer_core::types::sd::ChildRelationship::Internal
&& bem_children.contains(&edge.child.as_str())
&& !allowed.contains(&edge.parent)
{
allowed.push(edge.parent.clone());
}
}
let primary_wrapper = bem_children
.iter()
.find(|name| {
profiles.get(**name).is_some_and(|p| {
p.has_children_prop
&& p.children_slot_path
.first()
.is_some_and(|tag| matches!(tag.as_str(), "div" | "span"))
})
})
.unwrap_or(&bem_children[0]);
let allowed_list = allowed.join(", ");
checks.push(ConformanceCheck {
family: family.to_string(),
check_type: ConformanceCheckType::ExclusiveWrapper {
parent: root.clone(),
allowed_children: allowed.clone(),
},
description: format!(
"All children of {} must be wrapped in {}",
root, allowed_list
),
correct_example: Some(format!(
"<{}>\n <{}>\n {{/* your content */}}\n </{}>\n</{}>",
root, primary_wrapper, primary_wrapper, root
)),
});
}
checks
}
fn resolve_component_package(file_path: &str) -> Option<String> {
let parts: Vec<&str> = file_path.split('/').collect();
let pkg_idx = parts.iter().position(|&p| p == "packages")?;
let pkg_dir = parts.get(pkg_idx + 1)?;
let mut pkg_name = format!("@patternfly/{}", pkg_dir);
if parts.contains(&"deprecated") {
pkg_name.push_str("/deprecated");
} else if parts.contains(&"next") {
pkg_name.push_str("/next");
}
Some(pkg_name)
}
fn read_git_file(repo: &Path, git_ref: &str, file_path: &str) -> Option<String> {
let output = Command::new("git")
.args(["show", &format!("{}:{}", git_ref, file_path)])
.current_dir(repo)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_component_name() {
assert_eq!(
extract_component_name("packages/react-core/src/components/Dropdown/Dropdown.tsx"),
Some("Dropdown".to_string())
);
assert_eq!(
extract_component_name("packages/react-core/src/components/Modal/ModalHeader.tsx"),
Some("ModalHeader".to_string())
);
assert_eq!(
extract_component_name("packages/react-core/src/helpers/util.tsx"),
None
);
assert_eq!(
extract_component_name("packages/react-core/src/components/Dropdown/Dropdown.ts"),
None
);
}
#[test]
fn test_extract_family_from_path() {
assert_eq!(
extract_family_from_path("packages/react-core/src/components/Dropdown/Dropdown.tsx"),
Some("Dropdown".to_string())
);
assert_eq!(
extract_family_from_path("packages/react-core/src/components/Modal/ModalHeader.tsx"),
Some("Modal".to_string())
);
assert_eq!(extract_family_from_path("src/helpers/util.tsx"), None);
}
#[test]
fn test_should_exclude_from_sd() {
assert!(should_exclude_from_sd(
"src/components/Dropdown/Dropdown.test.tsx"
));
assert!(should_exclude_from_sd(
"src/components/Dropdown/Dropdown.spec.tsx"
));
assert!(should_exclude_from_sd(
"src/components/Dropdown/__tests__/Dropdown.tsx"
));
assert!(should_exclude_from_sd("src/components/Dropdown/index.tsx"));
assert!(should_exclude_from_sd("dist/components/Dropdown.tsx"));
assert!(should_exclude_from_sd(
"src/components/Dropdown/examples/Basic.tsx"
));
assert!(!should_exclude_from_sd(
"src/components/Dropdown/Dropdown.tsx"
));
}
#[test]
fn test_parse_index_exports() {
let content = r#"
export { Dropdown } from './Dropdown';
export { DropdownItem } from './DropdownItem';
export { DropdownList } from './DropdownList';
export type { DropdownProps } from './Dropdown';
"#;
let exports = parse_index_exports(content, "Dropdown");
assert_eq!(exports, vec!["Dropdown", "DropdownItem", "DropdownList"]);
}
#[test]
fn test_parse_index_exports_star() {
let content = r#"
export * from './Modal';
export * from './ModalHeader';
export * from './ModalBody';
export * from './ModalFooter';
"#;
let exports = parse_index_exports(content, "Modal");
assert_eq!(
exports,
vec!["Modal", "ModalHeader", "ModalBody", "ModalFooter"]
);
}
#[test]
fn test_parse_index_exports_default_as() {
let content = r#"
export { default as Dropdown } from './Dropdown';
export { default as DropdownItem } from './DropdownItem';
"#;
let exports = parse_index_exports(content, "Dropdown");
assert_eq!(exports, vec!["Dropdown", "DropdownItem"]);
}
#[test]
fn test_parse_index_exports_family_first() {
let content = r#"
export { DropdownItem } from './DropdownItem';
export { Dropdown } from './Dropdown';
export { DropdownList } from './DropdownList';
"#;
let exports = parse_index_exports(content, "Dropdown");
assert_eq!(exports[0], "Dropdown");
assert!(exports.contains(&"DropdownItem".to_string()));
assert!(exports.contains(&"DropdownList".to_string()));
}
#[test]
fn test_extract_from_path() {
assert_eq!(
extract_from_path("export { Dropdown } from './Dropdown';"),
Some("./Dropdown".to_string())
);
assert_eq!(
extract_from_path("export * from \"./Modal\";"),
Some("./Modal".to_string())
);
assert_eq!(extract_from_path("export { Dropdown };"), None);
}
#[test]
fn test_generate_conformance_checks() {
use semver_analyzer_core::types::sd::{ChildRelationship, CompositionEdge};
let tree = CompositionTree {
root: "Dropdown".to_string(),
family_members: vec![
"Dropdown".to_string(),
"DropdownList".to_string(),
"DropdownItem".to_string(),
],
edges: vec![
CompositionEdge {
parent: "Dropdown".to_string(),
child: "DropdownList".to_string(),
relationship: ChildRelationship::DirectChild,
required: true,
bem_evidence: None,
},
CompositionEdge {
parent: "DropdownList".to_string(),
child: "DropdownItem".to_string(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence: None,
},
],
};
let checks = generate_conformance_checks("Dropdown", &tree, &HashMap::new());
assert!(checks.iter().any(|c| matches!(
&c.check_type,
ConformanceCheckType::MissingChild {
parent,
expected_child
} if parent == "Dropdown" && expected_child == "DropdownList"
)));
assert!(checks.iter().any(|c| matches!(
&c.check_type,
ConformanceCheckType::InvalidDirectChild {
parent,
child,
expected_parent
} if parent == "Dropdown" && child == "DropdownItem" && expected_parent == "DropdownList"
)));
}
#[test]
fn test_parse_component_file_list() {
let output = "packages/react-core/src/components/Modal/Modal.tsx\n\
packages/react-core/src/components/Modal/ModalHeader.tsx\n\
packages/react-core/src/helpers/util.tsx\n\
packages/react-core/src/components/Modal/Modal.test.tsx\n\
packages/react-core/src/components/Modal/index.tsx\n";
let files = parse_component_file_list(output);
assert_eq!(files.len(), 2); assert_eq!(files[0].component_name, "Modal");
assert_eq!(files[1].component_name, "ModalHeader");
assert_eq!(files[0].family, Some("Modal".to_string()));
}
use crate::css_profile::{CssBlockProfile, CssElementInfo};
use semver_analyzer_core::types::sd::{CompositionEdge, CompositionTree};
use std::collections::BTreeMap;
#[allow(dead_code)]
fn make_css_element(display: &str, is_flex: bool) -> CssElementInfo {
let mut info = CssElementInfo::default();
info.display_values.insert(display.to_string());
if is_flex {
info.display_values.insert("flex".to_string());
}
info
}
fn make_source_profile(name: &str) -> ComponentSourceProfile {
ComponentSourceProfile {
name: name.to_string(),
..Default::default()
}
}
fn make_source_profile_with_block(name: &str, block: &str) -> ComponentSourceProfile {
ComponentSourceProfile {
name: name.to_string(),
bem_block: Some(block.to_string()),
..Default::default()
}
}
#[test]
fn test_self_referential_edge_blocked_page_sidebar() {
let mut trees = vec![CompositionTree {
root: "Page".into(),
family_members: vec![
"Page".into(),
"PageSidebar".into(),
"PageSidebarBody".into(),
],
edges: vec![
CompositionEdge {
parent: "Page".into(),
child: "PageSidebar".into(),
relationship: semver_analyzer_core::types::sd::ChildRelationship::DirectChild,
required: false,
bem_evidence: Some("BEM element".into()),
},
CompositionEdge {
parent: "Page".into(),
child: "PageSidebarBody".into(),
relationship: semver_analyzer_core::types::sd::ChildRelationship::DirectChild,
required: false,
bem_evidence: Some("BEM element".into()),
},
],
}];
let mut elements = BTreeMap::new();
elements.insert("sidebar".to_string(), {
let mut info = CssElementInfo::default();
info.display_values.insert("flex".to_string());
info
});
elements.insert("sidebar-body".to_string(), CssElementInfo::default());
let css_profile = CssBlockProfile {
block: "page".into(),
elements,
has_containment: vec![],
descendant_nesting: vec![],
sibling_relationships: vec![],
};
let mut css_profiles = HashMap::new();
css_profiles.insert("page".to_string(), css_profile);
let mut react_profiles = HashMap::new();
react_profiles.insert(
"Page".to_string(),
make_source_profile_with_block("Page", "page"),
);
react_profiles.insert(
"PageSidebar".to_string(),
make_source_profile("PageSidebar"),
);
react_profiles.insert(
"PageSidebarBody".to_string(),
make_source_profile("PageSidebarBody"),
);
enrich_trees_with_css(&mut trees, &css_profiles, &react_profiles);
let self_edges: Vec<_> = trees[0]
.edges
.iter()
.filter(|e| e.parent == e.child)
.collect();
assert!(
self_edges.is_empty(),
"Self-referential edges must be blocked. Found: {:?}",
self_edges
);
}
#[test]
fn test_has_containment_prevents_sibling_nesting_textinputgroup() {
let mut trees = vec![CompositionTree {
root: "TextInputGroup".into(),
family_members: vec![
"TextInputGroup".into(),
"TextInputGroupMain".into(),
"TextInputGroupUtilities".into(),
],
edges: vec![
CompositionEdge {
parent: "TextInputGroup".into(),
child: "TextInputGroupMain".into(),
relationship: semver_analyzer_core::types::sd::ChildRelationship::DirectChild,
required: false,
bem_evidence: Some("BEM element".into()),
},
CompositionEdge {
parent: "TextInputGroup".into(),
child: "TextInputGroupUtilities".into(),
relationship: semver_analyzer_core::types::sd::ChildRelationship::DirectChild,
required: false,
bem_evidence: Some("BEM element".into()),
},
],
}];
let mut elements = BTreeMap::new();
elements.insert("main".to_string(), {
let mut info = CssElementInfo::default();
info.display_values.insert("flex".to_string());
info
});
elements.insert("utilities".to_string(), {
let mut info = CssElementInfo::default();
info.display_values.insert("flex".to_string());
info
});
let css_profile = CssBlockProfile {
block: "text-input-group".into(),
elements,
has_containment: vec![("".to_string(), "utilities".to_string())],
descendant_nesting: vec![],
sibling_relationships: vec![],
};
let mut css_profiles = HashMap::new();
css_profiles.insert("text-input-group".to_string(), css_profile);
let mut react_profiles = HashMap::new();
react_profiles.insert(
"TextInputGroup".into(),
make_source_profile_with_block("TextInputGroup", "text-input-group"),
);
react_profiles.insert(
"TextInputGroupMain".into(),
make_source_profile("TextInputGroupMain"),
);
react_profiles.insert(
"TextInputGroupUtilities".into(),
make_source_profile("TextInputGroupUtilities"),
);
enrich_trees_with_css(&mut trees, &css_profiles, &react_profiles);
let bad_nesting: Vec<_> = trees[0]
.edges
.iter()
.filter(|e| e.parent == "TextInputGroupMain" && e.child == "TextInputGroupUtilities")
.collect();
assert!(
bad_nesting.is_empty(),
"TextInputGroupUtilities must NOT be nested inside TextInputGroupMain. Found: {:?}",
bad_nesting
);
let root_edges: Vec<_> = trees[0]
.edges
.iter()
.filter(|e| e.parent == "TextInputGroup" && e.child == "TextInputGroupUtilities")
.collect();
assert!(
!root_edges.is_empty(),
"TextInputGroupUtilities should remain as a child of TextInputGroup"
);
}
#[test]
fn test_css_descendant_nesting_card_header_title() {
let mut trees = vec![CompositionTree {
root: "Card".into(),
family_members: vec![
"Card".into(),
"CardHeader".into(),
"CardTitle".into(),
"CardBody".into(),
],
edges: vec![
CompositionEdge {
parent: "Card".into(),
child: "CardHeader".into(),
relationship: semver_analyzer_core::types::sd::ChildRelationship::DirectChild,
required: false,
bem_evidence: Some("BEM element".into()),
},
CompositionEdge {
parent: "Card".into(),
child: "CardTitle".into(),
relationship: semver_analyzer_core::types::sd::ChildRelationship::DirectChild,
required: false,
bem_evidence: Some("BEM element".into()),
},
CompositionEdge {
parent: "Card".into(),
child: "CardBody".into(),
relationship: semver_analyzer_core::types::sd::ChildRelationship::DirectChild,
required: false,
bem_evidence: Some("BEM element".into()),
},
],
}];
let mut elements = BTreeMap::new();
elements.insert("header".to_string(), {
let mut info = CssElementInfo::default();
info.display_values.insert("flex".to_string());
info
});
elements.insert("title".to_string(), CssElementInfo::default());
elements.insert("body".to_string(), CssElementInfo::default());
let css_profile = CssBlockProfile {
block: "card".into(),
elements,
has_containment: vec![],
descendant_nesting: vec![("header".to_string(), "title".to_string())],
sibling_relationships: vec![],
};
let mut css_profiles = HashMap::new();
css_profiles.insert("card".to_string(), css_profile);
let mut react_profiles = HashMap::new();
react_profiles.insert(
"Card".into(),
make_source_profile_with_block("Card", "card"),
);
react_profiles.insert("CardHeader".into(), make_source_profile("CardHeader"));
react_profiles.insert("CardTitle".into(), make_source_profile("CardTitle"));
react_profiles.insert("CardBody".into(), make_source_profile("CardBody"));
enrich_trees_with_css(&mut trees, &css_profiles, &react_profiles);
let header_title: Vec<_> = trees[0]
.edges
.iter()
.filter(|e| e.parent == "CardHeader" && e.child == "CardTitle")
.collect();
assert!(
!header_title.is_empty(),
"CardHeader → CardTitle should be created from CSS descendant selector"
);
let root_title: Vec<_> = trees[0]
.edges
.iter()
.filter(|e| e.parent == "Card" && e.child == "CardTitle")
.collect();
assert!(
root_title.is_empty(),
"Card → CardTitle root edge should be removed (subsumed by CardHeader → CardTitle)"
);
}
#[test]
fn test_css_sibling_selector_prevents_nesting() {
let mut trees = vec![CompositionTree {
root: "Page".into(),
family_members: vec![
"Page".into(),
"PageSidebar".into(),
"PageMainContainer".into(),
],
edges: vec![
CompositionEdge {
parent: "Page".into(),
child: "PageSidebar".into(),
relationship: semver_analyzer_core::types::sd::ChildRelationship::DirectChild,
required: false,
bem_evidence: Some("BEM element".into()),
},
CompositionEdge {
parent: "Page".into(),
child: "PageMainContainer".into(),
relationship: semver_analyzer_core::types::sd::ChildRelationship::DirectChild,
required: false,
bem_evidence: Some("BEM element".into()),
},
],
}];
let mut elements = BTreeMap::new();
elements.insert("sidebar".to_string(), {
let mut info = CssElementInfo::default();
info.display_values.insert("flex".to_string());
info
});
elements.insert("main-container".to_string(), CssElementInfo::default());
let css_profile = CssBlockProfile {
block: "page".into(),
elements,
has_containment: vec![],
descendant_nesting: vec![],
sibling_relationships: vec![("sidebar".to_string(), "main-container".to_string())],
};
let mut css_profiles = HashMap::new();
css_profiles.insert("page".to_string(), css_profile);
let mut react_profiles = HashMap::new();
react_profiles.insert(
"Page".into(),
make_source_profile_with_block("Page", "page"),
);
react_profiles.insert("PageSidebar".into(), make_source_profile("PageSidebar"));
react_profiles.insert(
"PageMainContainer".into(),
make_source_profile("PageMainContainer"),
);
enrich_trees_with_css(&mut trees, &css_profiles, &react_profiles);
let bad_nesting: Vec<_> = trees[0]
.edges
.iter()
.filter(|e| e.parent == "PageSidebar" && e.child == "PageMainContainer")
.collect();
assert!(bad_nesting.is_empty(),
"CSS sibling selector proves PageMainContainer is a sibling of PageSidebar. Found: {:?}", bad_nesting);
}
}