use crate::composition::{build_composition_tree_v2, DelegateContext};
use crate::source_profile::{self, diff::diff_profiles};
use crate::sd_types::{
ComponentSourceProfile, CompositionChange, CompositionChangeType, CompositionTree,
ConformanceCheck, ConformanceCheckType, SdPipelineResult, SourceLevelCategory,
SourceLevelChange,
};
use anyhow::{Context, Result};
use semver_analyzer_core::types::ChangedFunction;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::Path;
use std::process::Command;
use tracing::{debug, info, info_span, trace, warn};
pub fn run_sd(
repo: &Path,
from_ref: &str,
to_ref: &str,
css_profiles: Option<&HashMap<String, crate::css_profile::CssBlockProfile>>,
from_worktree_path: Option<&Path>,
to_worktree_path: Option<&Path>,
) -> 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();
let mut deprecated_removed_profiles: HashMap<String, ComponentSourceProfile> = HashMap::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);
if new_source.is_none() && file_info.path.contains("/deprecated/") {
deprecated_removed_profiles
.entry(file_info.component_name.clone())
.or_insert_with(|| profile.clone());
}
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();
let mut deprecated_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 {
let evicted = new_profiles.insert(file_info.component_name.clone(), profile);
if let Some(dep_prof) = evicted {
deprecated_profiles.insert(file_info.component_name.clone(), dep_prof);
}
} else if is_deprecated {
deprecated_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"
);
if !deprecated_removed_profiles.is_empty() {
let mut deprecated_migration_count = 0;
for (component_name, deprecated_profile) in &deprecated_removed_profiles {
if let Some(replacement_profile) = new_profiles.get(component_name) {
info!(
component = %component_name,
deprecated_path = %deprecated_profile.file,
replacement_path = %replacement_profile.file,
"Diffing deprecated component against non-deprecated replacement"
);
let changes = diff_profiles(deprecated_profile, replacement_profile);
if !changes.is_empty() {
debug!(
component = %component_name,
changes = changes.len(),
"deprecated migration changes detected"
);
let tagged_changes: Vec<_> = changes
.into_iter()
.map(|mut c| {
c.migration_from = Some(deprecated_profile.file.clone());
c
})
.collect();
deprecated_migration_count += tagged_changes.len();
all_source_changes.extend(tagged_changes);
}
} else {
debug!(
component = %component_name,
"No non-deprecated replacement found — skipping migration diff"
);
}
}
if deprecated_migration_count > 0 {
info!(
changes = deprecated_migration_count,
components = deprecated_removed_profiles
.keys()
.filter(|name| new_profiles.contains_key(*name))
.count(),
"Phase A.5 complete: deprecated migration diffing"
);
}
}
let enrichment_count =
enrich_all_props_from_extends(repo, to_ref, &mut new_profiles, to_worktree_path);
let old_enrichment_count =
enrich_all_props_from_extends(repo, from_ref, &mut old_profiles, from_worktree_path);
if enrichment_count + old_enrichment_count > 0 {
info!(
new = enrichment_count,
old = old_enrichment_count,
"Phase B.5 complete: extends resolution enrichment"
);
}
let changed_functions = {
let parser = crate::diff_parser::TsDiffParser::new();
match parser.parse_changed_functions(repo, from_ref, to_ref) {
Ok(fns) => fns,
Err(e) => {
warn!(%e, "parse_changed_functions failed, transitive analysis will be skipped");
Vec::new()
}
}
};
if !changed_functions.is_empty() {
let transitive_changes =
analyze_managed_attr_dependencies(&changed_functions, &old_profiles, &new_profiles);
if !transitive_changes.is_empty() {
info!(
changes = transitive_changes.len(),
"Phase A.7 complete: transitive behavioral changes detected"
);
all_source_changes.extend(transitive_changes);
}
}
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<CompositionTree> = Vec::new();
let mut family_exports_map: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut resolved_trees: HashMap<String, CompositionTree> = HashMap::new();
struct FamilyBuildInfo {
family_name: String,
new_exports: Vec<String>,
all_members_for_tree: Vec<String>,
all_family_profiles: HashMap<String, ComponentSourceProfile>,
family_css_profile_key: Option<String>, }
let mut component_to_family: HashMap<String, String> = HashMap::new();
let mut build_infos: Vec<FamilyBuildInfo> = Vec::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,
&deprecated_profiles,
&all_member_names,
family_name,
);
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());
}
}
for name in &all_members_for_tree {
component_to_family.insert(name.clone(), family_name.clone());
}
let css_key = css_profiles.and_then(|css_profs| {
let root_name = new_exports.first()?;
if let Some(root_prof) = all_family_profiles.get(root_name) {
if let Some(ref block) = root_prof.bem_block {
if css_profs.contains_key(block.as_str()) {
return Some(block.clone());
}
}
}
let mut block_counts: HashMap<&str, usize> = HashMap::new();
for prof in all_family_profiles.values() {
if let Some(ref block) = prof.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)?;
if css_profs.contains_key(dominant) {
Some(dominant.to_string())
} else {
None
}
});
build_infos.push(FamilyBuildInfo {
family_name: family_name.clone(),
new_exports,
all_members_for_tree,
all_family_profiles,
family_css_profile_key: css_key,
});
}
let mut family_delegates: HashMap<String, HashSet<String>> = HashMap::new();
let mut family_wrapper_maps: HashMap<String, HashMap<String, String>> = HashMap::new();
for info in &build_infos {
let mut delegate_families: HashSet<String> = HashSet::new();
let mut wrapper_map: HashMap<String, String> = HashMap::new();
for member_name in &info.all_members_for_tree {
let Some(profile) = info.all_family_profiles.get(member_name) else {
continue;
};
for ext in &profile.extends_props {
let delegate_name = ext.strip_suffix("Props").unwrap_or(ext).to_string();
if let Some(delegate_family) = component_to_family.get(&delegate_name) {
if delegate_family != &info.family_name {
delegate_families.insert(delegate_family.clone());
wrapper_map.insert(member_name.clone(), delegate_name);
}
}
}
}
if !delegate_families.is_empty() {
family_delegates.insert(info.family_name.clone(), delegate_families);
family_wrapper_maps.insert(info.family_name.clone(), wrapper_map);
}
}
let build_family_tree = |info: &FamilyBuildInfo,
delegate_ctxs: &[DelegateContext<'_>],
css_profiles: Option<
&HashMap<String, crate::css_profile::CssBlockProfile>,
>|
-> Option<(CompositionTree, Vec<String>)> {
let full_tree = build_composition_tree_v2(
&info.all_family_profiles,
&info.all_members_for_tree,
css_profiles,
info.family_css_profile_key.as_deref(),
delegate_ctxs,
Some(&info.new_exports),
);
full_tree.map(|mut tree| {
let exports_set: HashSet<&str> = info.new_exports.iter().map(|s| s.as_str()).collect();
collapse_internal_nodes(&mut tree, &exports_set);
tree.root = info.family_name.clone();
(tree, info.new_exports.clone())
})
};
let mut deferred_indices: Vec<usize> = Vec::new();
for (idx, info) in build_infos.iter().enumerate() {
if family_delegates.contains_key(&info.family_name) {
deferred_indices.push(idx);
family_exports_map.insert(info.family_name.clone(), info.new_exports.clone());
continue;
}
if let Some((tree, exports)) = build_family_tree(info, &[], css_profiles) {
resolved_trees.insert(info.family_name.clone(), tree.clone());
composition_trees.push(tree);
family_exports_map.insert(info.family_name.clone(), exports);
} else {
family_exports_map.insert(info.family_name.clone(), info.new_exports.clone());
}
}
debug!(
independent = build_infos.len() - deferred_indices.len(),
deferred = deferred_indices.len(),
"Phase B1: independent trees built"
);
let mut remaining = deferred_indices;
for iteration in 0..10 {
if remaining.is_empty() {
break;
}
let mut still_remaining = Vec::new();
let mut resolved_this_round = 0;
for &idx in &remaining {
let info = &build_infos[idx];
let deps = &family_delegates[&info.family_name];
let all_resolved = deps.iter().all(|d| resolved_trees.contains_key(d));
if !all_resolved {
still_remaining.push(idx);
continue;
}
let wrapper_map = family_wrapper_maps
.get(&info.family_name)
.cloned()
.unwrap_or_default();
let mut per_delegate: HashMap<&str, HashMap<String, String>> = HashMap::new();
for (wrapper, delegate) in &wrapper_map {
if let Some(del_family) = component_to_family.get(delegate) {
per_delegate
.entry(del_family.as_str())
.or_default()
.insert(wrapper.clone(), delegate.clone());
}
}
let delegate_ctxs: Vec<DelegateContext<'_>> = per_delegate
.iter()
.filter_map(|(del_family, mapping)| {
let tree = resolved_trees.get(*del_family)?;
Some(DelegateContext {
delegate_tree: tree,
wrapper_to_delegate: mapping.clone(),
})
})
.collect();
debug!(
family = %info.family_name,
delegates = ?deps,
mappings = delegate_ctxs.len(),
iteration,
"resolving deferred family"
);
if let Some((tree, _exports)) = build_family_tree(info, &delegate_ctxs, css_profiles) {
resolved_trees.insert(info.family_name.clone(), tree.clone());
composition_trees.push(tree);
resolved_this_round += 1;
}
}
debug!(
iteration,
resolved = resolved_this_round,
remaining = still_remaining.len(),
"Phase B1 deferred resolution"
);
if resolved_this_round == 0 {
for &idx in &still_remaining {
let info = &build_infos[idx];
let unresolved: Vec<&String> = family_delegates[&info.family_name]
.iter()
.filter(|d| !resolved_trees.contains_key(*d))
.collect();
tracing::warn!(
family = %info.family_name,
unresolved_deps = ?unresolved,
"building without delegate context (deps not resolved)"
);
if let Some((tree, _exports)) = build_family_tree(info, &[], css_profiles) {
resolved_trees.insert(info.family_name.clone(), tree.clone());
composition_trees.push(tree);
}
}
break;
}
remaining = still_remaining;
}
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_v2(
&old_family_profiles,
&old_exports,
None,
None,
&[],
None,
);
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(), dead_css_classes_after_swap: Vec::new(), deprecated_replacements: 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()
.context("Failed to run 'git diff' for changed component discovery")?;
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()
.context("Failed to run 'git ls-tree' for component file listing")?;
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.")
|| path.contains("/code-connect/")
}
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() {
let component_dir = parts[i + 1];
if i > 0 {
let prev = parts[i - 1];
if prev == "deprecated" || prev == "next" {
return Some(format!("{}/{}", prev, component_dir));
}
}
return Some(component_dir.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, crate::sd_types::ComponentSourceProfile>,
deprecated_profiles: &HashMap<String, crate::sd_types::ComponentSourceProfile>,
family_exports: &[String],
family_name: &str,
) -> HashMap<String, crate::sd_types::ComponentSourceProfile> {
let is_deprecated_family = family_name.starts_with("deprecated/");
family_exports
.iter()
.filter_map(|name| {
if is_deprecated_family {
if let Some(dep_prof) = deprecated_profiles.get(name) {
return Some((name.clone(), dep_prof.clone()));
}
}
all_profiles.get(name).map(|p| (name.clone(), p.clone()))
})
.collect()
}
fn extract_family_profiles_at_ref(
repo: &Path,
git_ref: &str,
exports: &[String],
family_files: &[&ComponentFile],
) -> HashMap<String, crate::sd_types::ComponentSourceProfile> {
let mut profiles = HashMap::new();
for name in exports {
if let Some(cf) = family_files.iter().find(|f| f.component_name == *name) {
if let Some(source) = read_git_file(repo, git_ref, &cf.path) {
let profile = crate::source_profile::extract_profile(name, &cf.path, &source);
profiles.insert(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())
}
#[allow(dead_code)]
fn camel_to_kebab(s: &str) -> String {
let mut result = String::with_capacity(s.len() + 4);
for (i, ch) in s.chars().enumerate() {
if ch.is_uppercase() {
if i > 0 {
result.push('-');
}
result.push(ch.to_ascii_lowercase());
} else {
result.push(ch);
}
}
result
}
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 remaining: HashSet<String> = internal_nodes.clone();
let mut collapsed_set: HashSet<String> = HashSet::new();
let mut iteration = 0usize;
loop {
iteration += 1;
if iteration > 200 {
tracing::warn!(
root = %tree.root,
iteration,
remaining = remaining.len(),
"collapse_internal_nodes: exceeded 200 iterations, breaking"
);
break;
}
let next = remaining
.iter()
.find(|node| {
let all_children_resolved = tree
.edges
.iter()
.filter(|e| e.parent == **node)
.all(|e| !remaining.contains(&e.child) || **node == e.child);
let has_edges = tree
.edges
.iter()
.any(|e| e.child == **node || e.parent == **node);
all_children_resolved && has_edges
})
.cloned();
let next = next.or_else(|| {
remaining
.iter()
.find(|node| {
let has_parent = tree.edges.iter().any(|e| e.child == **node);
let has_child = tree.edges.iter().any(|e| e.parent == **node);
has_parent && has_child
})
.cloned()
});
let Some(node) = next else {
tree.edges
.retain(|e| !remaining.contains(&e.parent) && !remaining.contains(&e.child));
break;
};
let parent_edges: Vec<crate::sd_types::CompositionEdge> = tree
.edges
.iter()
.filter(|e| e.child == node)
.cloned()
.collect();
let child_edges: Vec<crate::sd_types::CompositionEdge> = tree
.edges
.iter()
.filter(|e| e.parent == node)
.cloned()
.collect();
let mut new_edges = Vec::new();
for parent_edge in &parent_edges {
for child_edge in &child_edges {
if parent_edge.parent == child_edge.child {
continue;
}
if collapsed_set.contains(&child_edge.child) {
continue;
}
let strength = if parent_edge.strength.parent_requires_child()
&& !parent_edge.strength.child_requires_parent()
{
child_edge.strength.clone()
} else if !parent_edge.strength.parent_requires_child()
&& !parent_edge.strength.child_requires_parent()
{
crate::sd_types::EdgeStrength::Allowed
} else {
parent_edge.strength.collapse_chain(&child_edge.strength)
};
let child_is_bem = child_edge
.bem_evidence
.as_ref()
.is_some_and(|ev| ev.contains("BEM element"));
new_edges.push(crate::sd_types::CompositionEdge {
parent: parent_edge.parent.clone(),
child: child_edge.child.clone(),
relationship: child_edge.relationship.clone(),
required: child_edge.required,
bem_evidence: Some(format!(
"Collapsed through internal {}: {} → {} → {}{}",
node,
parent_edge.parent,
node,
child_edge.child,
if child_is_bem { " (BEM element)" } else { "" }
)),
strength,
prop_name: child_edge.prop_name.clone(),
});
}
}
tree.edges.retain(|e| e.parent != node && e.child != node);
tree.edges.extend(new_edges);
collapsed_set.insert(node.clone());
remaining.remove(&node);
if remaining.is_empty() {
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 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 edge.relationship == crate::sd_types::ChildRelationship::Internal {
continue;
}
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), &crate::sd_types::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());
}
let mut depth: HashMap<&str, usize> = HashMap::new();
let mut queue = std::collections::VecDeque::new();
let root_name = tree.root.as_str();
if tree
.edges
.iter()
.any(|e| e.parent == root_name || e.child == root_name)
{
depth.insert(root_name, 0);
queue.push_back(root_name);
} else {
let base = root_name.rsplit('/').next().unwrap_or(root_name);
if let Some(member) = tree.family_members.iter().find(|m| m.as_str() == base) {
depth.insert(member.as_str(), 0);
queue.push_back(member.as_str());
}
}
while let Some(node) = queue.pop_front() {
let node_depth = depth[node];
for edge in &tree.edges {
if edge.parent == node
&& edge.relationship != crate::sd_types::ChildRelationship::Internal
&& !depth.contains_key(edge.child.as_str())
{
depth.insert(edge.child.as_str(), node_depth + 1);
queue.push_back(edge.child.as_str());
}
}
}
let depth_after_pass1: HashSet<&str> = depth.keys().copied().collect();
for (node, _) in depth.clone() {
queue.push_back(node);
}
while let Some(node) = queue.pop_front() {
let node_depth = depth[node];
for edge in &tree.edges {
if edge.parent == node && !depth.contains_key(edge.child.as_str()) {
depth.insert(edge.child.as_str(), node_depth + 1);
queue.push_back(edge.child.as_str());
}
}
}
let pass2_nodes: Vec<(&str, usize)> = depth
.iter()
.filter(|(node, _)| !depth_after_pass1.contains(*node))
.map(|(&node, &d)| (node, d))
.collect();
for (node, node_depth) in pass2_nodes {
for edge in &tree.edges {
if edge.parent == node && edge.strength == crate::sd_types::EdgeStrength::Required {
let child = edge.child.as_str();
let new_depth = node_depth + 1;
if let Some(¤t) = depth.get(child) {
if new_depth > current {
depth.insert(child, new_depth);
}
}
}
}
}
for edge in &tree.edges {
if edge.relationship == crate::sd_types::ChildRelationship::Internal {
continue;
}
if edge.strength == crate::sd_types::EdgeStrength::Allowed {
continue;
}
let parent_depth = depth.get(edge.parent.as_str()).copied();
let child_depth = depth.get(edge.child.as_str()).copied();
if let (Some(pd), Some(cd)) = (parent_depth, child_depth) {
if cd <= pd {
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 {
let child_has_chp_to_grandparent = tree.edges.iter().any(|e| {
e.child == edge.child
&& e.parent == *grandparent
&& e.relationship != crate::sd_types::ChildRelationship::Internal
&& e.strength.child_requires_parent()
});
if child_has_chp_to_grandparent {
continue;
}
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 == crate::sd_types::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"))
})
});
let non_bem_count = direct_child_edges.len() - bem_children.len();
if has_generic_wrapper && bem_children.len() >= 2 && non_bem_count == 0 {
let mut allowed: Vec<String> = bem_children.iter().map(|s| s.to_string()).collect();
for edge in &tree.edges {
if edge.relationship == crate::sd_types::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)
}
use crate::git_utils::read_git_file;
fn analyze_managed_attr_dependencies(
changed_functions: &[ChangedFunction],
old_profiles: &HashMap<String, ComponentSourceProfile>,
new_profiles: &HashMap<String, ComponentSourceProfile>,
) -> Vec<SourceLevelChange> {
let _span = info_span!("phase_a7_transitive").entered();
let mut changes = Vec::new();
let mut generator_to_components: HashMap<String, Vec<String>> = HashMap::new();
for (component_name, profile) in new_profiles.iter().chain(old_profiles.iter()) {
for binding in &profile.managed_attributes {
generator_to_components
.entry(binding.generator_function.clone())
.or_default()
.push(component_name.clone());
}
}
if generator_to_components.is_empty() {
return changes;
}
for changed_fn in changed_functions {
let fn_name = &changed_fn.name;
let affected_components = match generator_to_components.get(fn_name) {
Some(components) => components,
None => continue,
};
debug!(
function = %fn_name,
file = %changed_fn.file.display(),
affected = affected_components.len(),
"Changed helper matches managed attribute generator"
);
let (old_body, new_body) = match (&changed_fn.old_body, &changed_fn.new_body) {
(Some(old), Some(new)) => (old.as_str(), new.as_str()),
_ => {
continue;
}
};
let output_changes = diff_helper_output(fn_name, old_body, new_body);
if output_changes.is_empty() {
continue;
}
let mut seen_components = HashSet::new();
for component_name in affected_components {
if !seen_components.insert(component_name.clone()) {
continue;
}
let binding = new_profiles
.get(component_name)
.or_else(|| old_profiles.get(component_name))
.and_then(|p| {
p.managed_attributes
.iter()
.find(|b| b.generator_function == *fn_name)
});
let overridden_attrs: Vec<String> = binding
.map(|b| b.overridden_attributes.clone())
.unwrap_or_default();
for (attr_name, old_val, new_val) in &output_changes {
if !overridden_attrs.is_empty() && !overridden_attrs.contains(attr_name) {
continue;
}
let description = format!(
"{component_name}'s `{attr_name}` value changed from \
\"{old_val}\" to \"{new_val}\" via {fn_name}(). \
Update any code that matches on the old attribute value."
);
let dep_chain = vec![
component_name.clone(),
fn_name.clone(),
changed_fn.file.display().to_string(),
];
changes.push(SourceLevelChange {
component: component_name.clone(),
category: SourceLevelCategory::DataAttribute,
description,
old_value: Some(format!("{attr_name}=\"{old_val}\"")),
new_value: Some(format!("{attr_name}=\"{new_val}\"")),
has_test_implications: true,
test_description: Some(format!(
"Tests querying `[{attr_name}=\"{old_val}\"]` will no longer \
match. Update selectors to use \"{new_val}\"."
)),
element: binding.map(|b| b.target_element.clone()),
migration_from: None,
dependency_chain: Some(dep_chain),
});
info!(
component = %component_name,
attribute = %attr_name,
old = %old_val,
new = %new_val,
"Transitive behavioral change: managed attribute output changed"
);
}
}
}
changes
}
fn diff_helper_output(
fn_name: &str,
old_body: &str,
new_body: &str,
) -> Vec<(String, String, String)> {
let mut changes = Vec::new();
let old_strings = extract_string_literals(old_body);
let new_strings = extract_string_literals(new_body);
for old_str in &old_strings {
if let Some((prefix, suffix)) = extract_version_prefix(old_str) {
for new_str in &new_strings {
if let Some((new_prefix, new_suffix)) = extract_version_prefix(new_str) {
if suffix == new_suffix && prefix != new_prefix {
changes.push((
"data-ouia-component-type".to_string(),
old_str.clone(),
new_str.clone(),
));
}
}
}
}
}
if changes.is_empty() {
let old_set: HashSet<&String> = old_strings.iter().collect();
let new_set: HashSet<&String> = new_strings.iter().collect();
let removed: Vec<_> = old_set.difference(&new_set).collect();
let added: Vec<_> = new_set.difference(&old_set).collect();
if removed.len() == 1 && added.len() == 1 {
changes.push((
format!("{fn_name}-output"),
(*removed[0]).clone(),
(*added[0]).clone(),
));
}
}
changes
}
fn extract_string_literals(body: &str) -> Vec<String> {
let mut strings = Vec::new();
let mut chars = body.chars().peekable();
while let Some(&ch) = chars.peek() {
if ch == '"' || ch == '\'' || ch == '`' {
let quote = ch;
chars.next(); let mut literal = String::new();
let mut escaped = false;
for next_ch in chars.by_ref() {
if escaped {
literal.push(next_ch);
escaped = false;
} else if next_ch == '\\' {
escaped = true;
} else if next_ch == quote {
break;
} else if quote == '`' && next_ch == '$' {
literal.push(next_ch);
} else {
literal.push(next_ch);
}
}
if !literal.is_empty() {
strings.push(literal);
}
} else {
chars.next();
}
}
strings
}
fn extract_version_prefix(s: &str) -> Option<(String, String)> {
if s.len() >= 4 && s.starts_with("PF") {
let digit_end = s[2..].find('/').map(|i| i + 2)?;
let prefix_part = &s[..digit_end];
if s[2..digit_end].chars().all(|c| c.is_ascii_digit()) {
let suffix = &s[digit_end..];
return Some((prefix_part.to_string(), suffix.to_string()));
}
}
None
}
fn enrich_all_props_from_extends(
repo: &Path,
git_ref: &str,
profiles: &mut HashMap<String, ComponentSourceProfile>,
worktree_path: Option<&Path>,
) -> usize {
let mut enriched_count = 0;
let resolver_map: Option<crate::resolve::ResolverMap> = worktree_path.map(|wt| {
let rm = crate::resolve::create_resolver_map(wt, 5);
debug!(
worktree = %wt.display(),
"Created ResolverMap for extends resolution"
);
rm
});
let needs_enrichment: Vec<(String, String, Vec<String>)> = profiles
.iter()
.filter(|(_, p)| !p.extends_props.is_empty())
.map(|(name, p)| (name.clone(), p.file.clone(), p.extends_props.clone()))
.collect();
for (component_name, file_path, extends_props) in &needs_enrichment {
let source = if let Some(wt) = worktree_path {
let full_path = wt.join(file_path);
std::fs::read_to_string(&full_path).ok()
} else {
read_git_file(repo, git_ref, file_path)
};
let source = match source {
Some(s) => s,
None => {
trace!(
component = %component_name,
file = %file_path,
"extends enrichment: could not read component source"
);
continue;
}
};
let imports = parse_import_sources(&source, file_path);
let mut newly_added_props = Vec::new();
for extends_type in extends_props {
let import_source = match find_import_for_type(&source, extends_type) {
Some(s) => s,
None => {
trace!(
component = %component_name,
extends_type = %extends_type,
"extends enrichment: no import found for type"
);
continue;
}
};
if let (Some(wt), Some(rm)) = (worktree_path, &resolver_map) {
let component_dir = std::path::Path::new(file_path)
.parent()
.unwrap_or(std::path::Path::new(""));
let full_component_dir = wt.join(component_dir);
let resolver = rm.resolver_for_file(&full_component_dir);
match resolver.resolve(&full_component_dir, &import_source) {
Ok(resolved) => {
let resolved_path = resolved.full_path();
if let Ok(resolved_src) = std::fs::read_to_string(&resolved_path) {
if let Some(props) =
extract_interface_props(&resolved_src, extends_type)
{
newly_added_props.extend(props);
} else {
resolve_reexports_with_resolver(
rm,
wt,
&resolved_src,
extends_type,
&resolved_path,
&mut newly_added_props,
);
}
}
}
Err(e) => {
trace!(
component = %component_name,
extends_type = %extends_type,
import_source = %import_source,
%e,
"extends enrichment: oxc_resolver failed"
);
}
}
continue;
}
let resolved = match resolve_relative_import(file_path, &import_source, &imports) {
Some(p) => p,
None => {
trace!(
component = %component_name,
extends_type = %extends_type,
import_source = %import_source,
"extends enrichment: could not resolve import (non-relative?)"
);
continue;
}
};
let interface_source = match read_git_file(repo, git_ref, &resolved) {
Some(s) => s,
None => {
if let Some(barrel_resolved) =
try_barrel_resolution(repo, git_ref, &resolved, extends_type)
{
if let Some(barrel_src) = read_git_file(repo, git_ref, &barrel_resolved) {
if let Some(props) = extract_interface_props(&barrel_src, extends_type)
{
newly_added_props.extend(props);
} else {
try_resolve_from_reexports(
repo,
git_ref,
&barrel_src,
extends_type,
&barrel_resolved,
&mut newly_added_props,
);
}
} else {
trace!(
component = %component_name,
extends_type = %extends_type,
barrel = %barrel_resolved,
"extends enrichment: barrel file unreadable"
);
}
} else {
trace!(
component = %component_name,
extends_type = %extends_type,
resolved = %resolved,
"extends enrichment: barrel resolution failed"
);
}
continue;
}
};
if let Some(props) = extract_interface_props(&interface_source, extends_type) {
newly_added_props.extend(props);
} else {
try_resolve_from_reexports(
repo,
git_ref,
&interface_source,
extends_type,
&resolved,
&mut newly_added_props,
);
}
}
if !newly_added_props.is_empty() {
if let Some(profile) = profiles.get_mut(component_name) {
let before = profile.all_props.len();
for prop in &newly_added_props {
profile.all_props.insert(prop.clone());
}
let added = profile.all_props.len() - before;
if added > 0 {
debug!(
component = %component_name,
added = added,
props = ?newly_added_props,
"Enriched all_props from extends"
);
profile.managed_attributes =
crate::source_profile::managed_attrs::extract_managed_attributes(
&source,
component_name,
&profile.all_props,
&profile.data_attributes,
);
enriched_count += 1;
} else {
trace!(
component = %component_name,
props = ?newly_added_props,
"extends enrichment: all props already present (0 new)"
);
}
}
} else {
trace!(
component = %component_name,
extends = ?extends_props,
"extends enrichment: no props resolved from any extends type"
);
}
}
enriched_count
}
fn parse_import_sources(source: &str, _file_path: &str) -> HashMap<String, String> {
let allocator = oxc_allocator::Allocator::default();
let source_type = oxc_span::SourceType::tsx();
let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
let mut imports = HashMap::new();
for item in &parsed.program.body {
if let oxc_ast::ast::Statement::ImportDeclaration(import) = item {
let module_source = import.source.value.to_string();
if let Some(specifiers) = &import.specifiers {
for spec in specifiers {
let local_name = match spec {
oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(named) => {
named.local.name.to_string()
}
oxc_ast::ast::ImportDeclarationSpecifier::ImportDefaultSpecifier(def) => {
def.local.name.to_string()
}
oxc_ast::ast::ImportDeclarationSpecifier::ImportNamespaceSpecifier(ns) => {
ns.local.name.to_string()
}
};
imports.insert(local_name, module_source.clone());
}
}
}
}
imports
}
fn find_import_for_type(source: &str, type_name: &str) -> Option<String> {
let allocator = oxc_allocator::Allocator::default();
let source_type = oxc_span::SourceType::tsx();
let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
for item in &parsed.program.body {
if let oxc_ast::ast::Statement::ImportDeclaration(import) = item {
if let Some(specifiers) = &import.specifiers {
for spec in specifiers {
let imported_name = match spec {
oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(named) => {
match &named.imported {
oxc_ast::ast::ModuleExportName::IdentifierName(id) => {
id.name.as_str()
}
oxc_ast::ast::ModuleExportName::IdentifierReference(id) => {
id.name.as_str()
}
oxc_ast::ast::ModuleExportName::StringLiteral(s) => {
s.value.as_str()
}
}
}
_ => continue,
};
if imported_name == type_name {
return Some(import.source.value.to_string());
}
}
}
}
}
None
}
fn resolve_relative_import(
component_file: &str,
import_source: &str,
_imports: &HashMap<String, String>,
) -> Option<String> {
if !import_source.starts_with('.') {
return None;
}
let component_dir = std::path::Path::new(component_file).parent()?;
let joined = component_dir.join(import_source);
let normalized = normalize_path(&joined);
Some(normalized)
}
fn normalize_path(path: &std::path::Path) -> String {
let mut parts: Vec<&std::ffi::OsStr> = Vec::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
parts.pop();
}
std::path::Component::CurDir => {}
other => {
parts.push(other.as_os_str());
}
}
}
parts
.iter()
.map(|s| s.to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
fn try_barrel_resolution(
repo: &Path,
git_ref: &str,
base_path: &str,
_type_name: &str,
) -> Option<String> {
let candidates = [
format!("{base_path}.ts"),
format!("{base_path}.tsx"),
format!("{base_path}/index.ts"),
format!("{base_path}/index.tsx"),
];
for candidate in &candidates {
if read_git_file(repo, git_ref, candidate).is_some() {
return Some(candidate.clone());
}
}
None
}
fn extract_interface_props(source: &str, type_name: &str) -> Option<Vec<String>> {
let allocator = oxc_allocator::Allocator::default();
let source_type = oxc_span::SourceType::tsx();
let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
for item in &parsed.program.body {
if let oxc_ast::ast::Statement::ExportNamedDeclaration(export) = item {
if let Some(oxc_ast::ast::Declaration::TSInterfaceDeclaration(iface)) =
&export.declaration
{
if iface.id.name.as_str() == type_name {
return Some(extract_props_from_interface_body(iface));
}
}
}
if let oxc_ast::ast::Statement::TSInterfaceDeclaration(iface) = item {
if iface.id.name.as_str() == type_name {
return Some(extract_props_from_interface_body(iface));
}
}
if let oxc_ast::ast::Statement::ExportNamedDeclaration(export) = item {
if let Some(oxc_ast::ast::Declaration::TSTypeAliasDeclaration(alias)) =
&export.declaration
{
if alias.id.name.as_str() == type_name {
if let oxc_ast::ast::TSType::TSTypeLiteral(lit) = &alias.type_annotation {
let mut props = Vec::new();
for member in &lit.members {
if let oxc_ast::ast::TSSignature::TSPropertySignature(prop) = member {
if let oxc_ast::ast::PropertyKey::StaticIdentifier(id) = &prop.key {
props.push(id.name.to_string());
}
}
}
return Some(props);
}
}
}
}
}
None
}
fn extract_props_from_interface_body(iface: &oxc_ast::ast::TSInterfaceDeclaration) -> Vec<String> {
let mut props = Vec::new();
for sig in &iface.body.body {
if let oxc_ast::ast::TSSignature::TSPropertySignature(prop) = sig {
if let oxc_ast::ast::PropertyKey::StaticIdentifier(id) = &prop.key {
props.push(id.name.to_string());
}
}
}
props
}
fn try_resolve_from_reexports(
repo: &Path,
git_ref: &str,
barrel_source: &str,
type_name: &str,
barrel_file: &str,
out: &mut Vec<String>,
) {
let candidates = find_reexport_sources(barrel_source, type_name, barrel_file);
for candidate_path in &candidates {
if let Some(src) = read_git_file(repo, git_ref, candidate_path) {
if let Some(props) = extract_interface_props(&src, type_name) {
out.extend(props);
return;
}
}
let base = candidate_path.trim_end_matches(".ts");
for ext in &[".tsx", "/index.ts", "/index.tsx"] {
let alt = format!("{base}{ext}");
if let Some(src) = read_git_file(repo, git_ref, &alt) {
if let Some(props) = extract_interface_props(&src, type_name) {
out.extend(props);
return;
}
}
}
}
}
fn resolve_reexports_with_resolver(
_resolver_map: &crate::resolve::ResolverMap,
worktree: &Path,
barrel_source: &str,
type_name: &str,
barrel_file: &std::path::Path,
out: &mut Vec<String>,
) {
let barrel_rel = barrel_file
.strip_prefix(worktree)
.unwrap_or(barrel_file)
.to_string_lossy()
.to_string();
let candidates = find_reexport_sources(barrel_source, type_name, &barrel_rel);
for candidate_path in &candidates {
let full_path = worktree.join(candidate_path);
if let Ok(src) = std::fs::read_to_string(&full_path) {
if let Some(props) = extract_interface_props(&src, type_name) {
out.extend(props);
return;
}
}
let base = candidate_path.trim_end_matches(".ts");
for ext in &[".tsx", "/index.ts", "/index.tsx"] {
let alt = format!("{base}{ext}");
let alt_path = worktree.join(&alt);
if let Ok(src) = std::fs::read_to_string(alt_path) {
if let Some(props) = extract_interface_props(&src, type_name) {
out.extend(props);
return;
}
}
}
}
}
fn find_reexport_sources(barrel_source: &str, type_name: &str, barrel_file: &str) -> Vec<String> {
let allocator = oxc_allocator::Allocator::default();
let source_type = oxc_span::SourceType::tsx();
let parsed = oxc_parser::Parser::new(&allocator, barrel_source, source_type).parse();
let barrel_dir = std::path::Path::new(barrel_file)
.parent()
.unwrap_or(std::path::Path::new(""));
let mut named = Vec::new();
let mut wildcards = Vec::new();
for item in &parsed.program.body {
if let oxc_ast::ast::Statement::ExportNamedDeclaration(export) = item {
if let Some(source) = &export.source {
let module_source = source.value.as_str();
let exports_type = export.specifiers.iter().any(|spec| {
let exported_name = match &spec.exported {
oxc_ast::ast::ModuleExportName::IdentifierName(id) => id.name.as_str(),
oxc_ast::ast::ModuleExportName::IdentifierReference(id) => id.name.as_str(),
oxc_ast::ast::ModuleExportName::StringLiteral(s) => s.value.as_str(),
};
exported_name == type_name
});
if exports_type && module_source.starts_with('.') {
let joined = barrel_dir.join(module_source);
let resolved = normalize_path(&joined);
named.push(format!("{resolved}.ts"));
}
}
}
if let oxc_ast::ast::Statement::ExportAllDeclaration(export_all) = item {
let module_source = export_all.source.value.as_str();
if module_source.starts_with('.') {
let joined = barrel_dir.join(module_source);
let resolved = normalize_path(&joined);
wildcards.push(format!("{resolved}.ts"));
}
}
}
named.extend(wildcards);
named
}
#[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 crate::sd_types::{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,
strength: crate::sd_types::EdgeStrength::Required,
prop_name: None,
},
CompositionEdge {
parent: "DropdownList".to_string(),
child: "DropdownItem".to_string(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence: None,
strength: crate::sd_types::EdgeStrength::Required,
prop_name: 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_conformance_checks_skip_back_edges() {
use crate::sd_types::{ChildRelationship, CompositionEdge};
let tree = CompositionTree {
root: "Tabs".to_string(),
family_members: vec!["Tabs".to_string(), "Tab".to_string()],
edges: vec![
CompositionEdge {
parent: "Tabs".to_string(),
child: "Tab".to_string(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence: None,
strength: crate::sd_types::EdgeStrength::Required,
prop_name: None,
},
CompositionEdge {
parent: "Tab".to_string(),
child: "Tabs".to_string(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence: None,
strength: crate::sd_types::EdgeStrength::Required,
prop_name: None,
},
],
};
let checks = generate_conformance_checks("Tabs", &tree, &HashMap::new());
assert!(
checks.iter().any(|c| {
c.description.contains("Tab")
&& c.description.contains("Tabs")
&& !c.description.contains("Tabs should be inside Tab")
&& !c.description.contains("Tabs must")
}),
"Expected a check for 'Tab must be in Tabs'"
);
assert!(
!checks.iter().any(|c| {
matches!(&c.check_type, ConformanceCheckType::InvalidDirectChild {
child, expected_parent, ..
} if child == "Tabs" && expected_parent == "Tab")
}),
"Back-edge should not produce InvalidDirectChild conformance check"
);
assert!(
!checks.iter().any(|c| {
matches!(&c.check_type, ConformanceCheckType::MissingChild {
parent, expected_child,
} if parent == "Tab" && expected_child == "Tabs")
}),
"Back-edge should not produce MissingChild conformance check"
);
}
#[test]
fn test_composition_changes_skip_internal_edges() {
use crate::sd_types::{ChildRelationship, CompositionEdge};
let old_tree = CompositionTree {
root: "Tabs".to_string(),
family_members: vec![
"Tabs".to_string(),
"Tab".to_string(),
"TabTitleText".to_string(),
],
edges: vec![CompositionEdge {
parent: "Tabs".to_string(),
child: "Tab".to_string(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence: None,
strength: crate::sd_types::EdgeStrength::Allowed,
prop_name: None,
}],
};
let new_tree = CompositionTree {
root: "Tabs".to_string(),
family_members: vec![
"Tabs".to_string(),
"Tab".to_string(),
"TabTitleText".to_string(),
],
edges: vec![
CompositionEdge {
parent: "Tabs".to_string(),
child: "Tab".to_string(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence: None,
strength: crate::sd_types::EdgeStrength::Allowed,
prop_name: None,
},
CompositionEdge {
parent: "Tab".to_string(),
child: "TabTitleText".to_string(),
relationship: ChildRelationship::Internal,
required: false,
bem_evidence: Some(
"Collapsed through internal OverflowTab: Tab → OverflowTab → TabTitleText"
.to_string(),
),
strength: crate::sd_types::EdgeStrength::Allowed,
prop_name: None,
},
],
};
let exports: Vec<String> = vec!["Tabs".into(), "Tab".into(), "TabTitleText".into()];
let changes =
diff_composition_trees("Tabs", Some(&old_tree), &new_tree, &exports, &exports);
let has_tab_tabtitletext = changes.iter().any(|c| {
matches!(&c.change_type, CompositionChangeType::NewRequiredChild {
parent, new_child, ..
} if parent == "Tab" && new_child == "TabTitleText")
});
assert!(
!has_tab_tabtitletext,
"Internal edges should not produce NewRequiredChild composition changes. \
Got changes: {:?}",
changes.iter().map(|c| &c.description).collect::<Vec<_>>()
);
}
#[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()));
}
#[test]
fn test_deprecated_migration_diff_produces_tagged_changes() {
use crate::sd_types::SourceLevelCategory;
let deprecated_profile = ComponentSourceProfile {
name: "Select".to_string(),
file: "packages/react-core/src/deprecated/components/Select/Select.tsx".to_string(),
rendered_components: vec!["TextInput".into(), "ChipGroup".into()],
..Default::default()
};
let replacement_profile = ComponentSourceProfile {
name: "Select".to_string(),
file: "packages/react-core/src/components/Select/Select.tsx".to_string(),
rendered_components: vec!["Menu".into()],
..Default::default()
};
let changes = diff_profiles(&deprecated_profile, &replacement_profile);
let rendered_changes: Vec<_> = changes
.iter()
.filter(|c| c.category == SourceLevelCategory::RenderedComponent)
.collect();
assert!(
!rendered_changes.is_empty(),
"Should detect rendered component differences"
);
let text_input_removed = rendered_changes
.iter()
.find(|c| c.old_value.as_deref() == Some("TextInput"));
assert!(
text_input_removed.is_some(),
"Should detect TextInput no longer rendered. Changes: {:?}",
rendered_changes
.iter()
.map(|c| (&c.old_value, &c.new_value))
.collect::<Vec<_>>()
);
for c in &changes {
assert_eq!(
c.component, "Select",
"Component name should be bare, not prefixed"
);
}
let tagged: Vec<_> = changes
.into_iter()
.map(|mut c| {
c.migration_from = Some(deprecated_profile.file.clone());
c
})
.collect();
for c in &tagged {
assert_eq!(
c.migration_from.as_deref(),
Some("packages/react-core/src/deprecated/components/Select/Select.tsx"),
"migration_from should be set to deprecated path"
);
assert_eq!(c.component, "Select", "component should remain bare");
}
}
#[test]
fn test_deprecated_without_replacement_skipped() {
let _deprecated_profile = ComponentSourceProfile {
name: "Tile".to_string(),
file: "packages/react-core/src/deprecated/components/Tile/Tile.tsx".to_string(),
rendered_components: vec!["Button".into()],
..Default::default()
};
let new_profiles: HashMap<String, ComponentSourceProfile> = HashMap::new();
assert!(
!new_profiles.contains_key("Tile"),
"No replacement should exist for Tile"
);
}
#[test]
fn test_migration_changes_separate_from_evolution_changes() {
use crate::sd_types::SourceLevelCategory;
let select_v5 = ComponentSourceProfile {
name: "Select".to_string(),
file: "packages/react-core/src/components/Select/Select.tsx".to_string(),
rendered_components: vec!["Menu".into()],
..Default::default()
};
let select_v6 = ComponentSourceProfile {
name: "Select".to_string(),
file: "packages/react-core/src/components/Select/Select.tsx".to_string(),
rendered_components: vec!["Menu".into(), "Popper".into()], ..Default::default()
};
let evolution_changes = diff_profiles(&select_v5, &select_v6);
let deprecated_select = ComponentSourceProfile {
name: "Select".to_string(),
file: "packages/react-core/src/deprecated/components/Select/Select.tsx".to_string(),
rendered_components: vec!["TextInput".into()],
..Default::default()
};
let migration_changes = diff_profiles(&deprecated_select, &select_v6);
let evolution: Vec<_> = evolution_changes
.into_iter()
.map(|mut c| {
c.migration_from = None; c
})
.collect();
let migration: Vec<_> = migration_changes
.into_iter()
.map(|mut c| {
c.migration_from = Some(deprecated_select.file.clone());
c
})
.collect();
for c in &evolution {
assert_eq!(c.component, "Select");
assert!(c.migration_from.is_none());
}
for c in &migration {
assert_eq!(c.component, "Select");
assert!(c.migration_from.is_some());
}
let text_input_change = migration.iter().find(|c| {
c.category == SourceLevelCategory::RenderedComponent
&& c.old_value.as_deref() == Some("TextInput")
});
assert!(
text_input_change.is_some(),
"Migration changes should include TextInput removal"
);
let text_input_in_evolution = evolution.iter().find(|c| {
c.category == SourceLevelCategory::RenderedComponent
&& c.old_value.as_deref() == Some("TextInput")
});
assert!(
text_input_in_evolution.is_none(),
"Evolution changes should not mention TextInput"
);
}
#[test]
fn test_collapse_three_level_internal_chain() {
use crate::sd_types::{ChildRelationship, CompositionEdge, CompositionTree, EdgeStrength};
let mut tree = CompositionTree {
root: "Modal".into(),
family_members: vec![
"Modal".into(),
"ModalBody".into(),
"ModalFooter".into(),
"ModalHeader".into(),
"ModalBox".into(),
"ModalBoxCloseButton".into(),
"ModalBoxDescription".into(),
"ModalBoxTitle".into(),
"ModalContent".into(),
],
edges: vec![
CompositionEdge {
parent: "Modal".into(),
child: "ModalContent".into(),
relationship: ChildRelationship::Internal,
required: true,
bem_evidence: Some("internally rendered".into()),
strength: EdgeStrength::Required,
prop_name: None,
},
CompositionEdge {
parent: "ModalContent".into(),
child: "ModalBox".into(),
relationship: ChildRelationship::Internal,
required: true,
bem_evidence: Some("internally rendered".into()),
strength: EdgeStrength::Required,
prop_name: None,
},
CompositionEdge {
parent: "ModalContent".into(),
child: "ModalBoxCloseButton".into(),
relationship: ChildRelationship::Internal,
required: true,
bem_evidence: Some("internally rendered".into()),
strength: EdgeStrength::Required,
prop_name: None,
},
CompositionEdge {
parent: "ModalHeader".into(),
child: "ModalBoxTitle".into(),
relationship: ChildRelationship::Internal,
required: true,
bem_evidence: Some("internally rendered".into()),
strength: EdgeStrength::Required,
prop_name: None,
},
CompositionEdge {
parent: "ModalHeader".into(),
child: "ModalBoxDescription".into(),
relationship: ChildRelationship::Internal,
required: true,
bem_evidence: Some("internally rendered".into()),
strength: EdgeStrength::Required,
prop_name: None,
},
CompositionEdge {
parent: "ModalBox".into(),
child: "ModalBody".into(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence: Some("secondary block fallback".into()),
strength: EdgeStrength::Structural,
prop_name: None,
},
CompositionEdge {
parent: "ModalBox".into(),
child: "ModalFooter".into(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence: Some("secondary block fallback".into()),
strength: EdgeStrength::Structural,
prop_name: None,
},
CompositionEdge {
parent: "ModalBox".into(),
child: "ModalHeader".into(),
relationship: ChildRelationship::DirectChild,
required: false,
bem_evidence: Some("secondary block fallback".into()),
strength: EdgeStrength::Structural,
prop_name: None,
},
],
};
let exports: HashSet<&str> = ["Modal", "ModalBody", "ModalFooter", "ModalHeader"]
.iter()
.copied()
.collect();
collapse_internal_nodes(&mut tree, &exports);
let modal_to_body = tree
.edges
.iter()
.any(|e| e.parent == "Modal" && e.child == "ModalBody");
let modal_to_footer = tree
.edges
.iter()
.any(|e| e.parent == "Modal" && e.child == "ModalFooter");
let modal_to_header = tree
.edges
.iter()
.any(|e| e.parent == "Modal" && e.child == "ModalHeader");
assert!(
modal_to_body,
"Expected Modal → ModalBody after collapse. Edges: {:?}",
tree.edges
);
assert!(
modal_to_footer,
"Expected Modal → ModalFooter after collapse. Edges: {:?}",
tree.edges
);
assert!(
modal_to_header,
"Expected Modal → ModalHeader after collapse. Edges: {:?}",
tree.edges
);
assert_eq!(
tree.family_members.len(),
4,
"Expected 4 exported members. Members: {:?}",
tree.family_members
);
let internal = [
"ModalContent",
"ModalBox",
"ModalBoxCloseButton",
"ModalBoxTitle",
"ModalBoxDescription",
];
for name in &internal {
assert!(
!tree.family_members.contains(&name.to_string()),
"{} should be removed from family_members. Members: {:?}",
name,
tree.family_members
);
}
for name in &internal {
assert!(
!tree
.edges
.iter()
.any(|e| e.parent == *name || e.child == *name),
"No edges should reference internal node {}. Edges: {:?}",
name,
tree.edges
);
}
}
#[test]
#[ignore] fn test_modal_family_integration_real_files() {
use crate::composition::build_composition_tree_v2;
use crate::css_profile::parse_css_for_test;
use crate::source_profile;
let modal_dir = "/tmp/semver-pipeline-v2/repos/patternfly-react/packages/react-core/src/components/Modal";
let css_file =
"/tmp/semver-pipeline-v2/repos/patternfly/dist/components/ModalBox/modal-box.css";
let component_files = [
("Modal", "Modal.tsx"),
("ModalBody", "ModalBody.tsx"),
("ModalBox", "ModalBox.tsx"),
("ModalBoxCloseButton", "ModalBoxCloseButton.tsx"),
("ModalBoxDescription", "ModalBoxDescription.tsx"),
("ModalBoxTitle", "ModalBoxTitle.tsx"),
("ModalContent", "ModalContent.tsx"),
("ModalFooter", "ModalFooter.tsx"),
("ModalHeader", "ModalHeader.tsx"),
];
let mut profiles = HashMap::new();
for (name, file) in &component_files {
let path = format!("{}/{}", modal_dir, file);
let source = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
let profile = source_profile::extract_profile(name, file, &source);
eprintln!(
"Profile {}: bem_block={:?}, rendered={:?}, css_tokens={:?}, has_children={}",
name,
profile.bem_block,
profile.rendered_components,
profile.css_tokens_used,
profile.has_children_prop,
);
profiles.insert(name.to_string(), profile);
}
let css_source = std::fs::read_to_string(css_file)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", css_file, e));
let modal_box_css =
parse_css_for_test(&css_source, "ModalBox").expect("Failed to parse modal-box.css");
eprintln!(
"CSS profile: block={}, elements={:?}",
modal_box_css.block,
modal_box_css.elements.keys().collect::<Vec<_>>()
);
let css_profiles = HashMap::from([(modal_box_css.block.clone(), modal_box_css)]);
let exports = vec![
"Modal".to_string(),
"ModalBody".to_string(),
"ModalHeader".to_string(),
"ModalFooter".to_string(),
];
let mut all_members = exports.clone();
for (name, _) in &component_files {
if !all_members.contains(&name.to_string()) {
all_members.push(name.to_string());
}
}
eprintln!("all_members: {:?}", all_members);
let root_block = profiles.get("Modal").and_then(|p| p.bem_block.as_deref());
let primary_key = if root_block.is_some_and(|b| css_profiles.contains_key(b)) {
root_block.map(|s| s.to_string())
} else {
let mut counts: HashMap<&str, usize> = HashMap::new();
for p in profiles.values() {
if let Some(ref b) = p.bem_block {
*counts.entry(b.as_str()).or_default() += 1;
}
}
counts
.into_iter()
.filter(|(b, _)| css_profiles.contains_key(*b))
.max_by_key(|(_, c)| *c)
.map(|(b, _)| b.to_string())
};
eprintln!("primary_css_block: {:?}", primary_key);
let tree = build_composition_tree_v2(
&profiles,
&all_members,
Some(&css_profiles),
primary_key.as_deref(),
&[],
Some(&exports),
)
.expect("Tree should be built");
eprintln!("Pre-collapse members: {:?}", tree.family_members);
eprintln!("Pre-collapse edges:");
for e in &tree.edges {
eprintln!(
" {} -> {} ({:?} / {:?}) {}",
e.parent,
e.child,
e.relationship,
e.strength,
e.bem_evidence.as_deref().unwrap_or("")
);
}
assert!(
tree.edges
.iter()
.any(|e| e.parent == "ModalBox" && e.child == "ModalBody"),
"Pre-collapse: expected ModalBox → ModalBody. Edges: {:?}",
tree.edges
);
assert!(
tree.edges
.iter()
.any(|e| e.parent == "ModalBox" && e.child == "ModalFooter"),
"Pre-collapse: expected ModalBox → ModalFooter. Edges: {:?}",
tree.edges
);
assert!(
tree.edges
.iter()
.any(|e| e.parent == "ModalBox" && e.child == "ModalHeader"),
"Pre-collapse: expected ModalBox → ModalHeader. Edges: {:?}",
tree.edges
);
let mut tree = tree;
let exports_set: HashSet<&str> = exports.iter().map(|s| s.as_str()).collect();
collapse_internal_nodes(&mut tree, &exports_set);
tree.root = "Modal".to_string();
eprintln!("\nPost-collapse members: {:?}", tree.family_members);
eprintln!("Post-collapse edges:");
for e in &tree.edges {
eprintln!(
" {} -> {} ({:?} / {:?}) {}",
e.parent,
e.child,
e.relationship,
e.strength,
e.bem_evidence.as_deref().unwrap_or("")
);
}
assert_eq!(
tree.family_members.len(),
4,
"Expected 4 members after collapse. Members: {:?}",
tree.family_members
);
let modal_to_body = tree
.edges
.iter()
.any(|e| e.parent == "Modal" && e.child == "ModalBody");
let modal_to_footer = tree
.edges
.iter()
.any(|e| e.parent == "Modal" && e.child == "ModalFooter");
let modal_to_header = tree
.edges
.iter()
.any(|e| e.parent == "Modal" && e.child == "ModalHeader");
assert!(
modal_to_body,
"Expected Modal → ModalBody after collapse. Edges: {:?}",
tree.edges
);
assert!(
modal_to_footer,
"Expected Modal → ModalFooter after collapse. Edges: {:?}",
tree.edges
);
assert!(
modal_to_header,
"Expected Modal → ModalHeader after collapse. Edges: {:?}",
tree.edges
);
let internals = [
"ModalContent",
"ModalBox",
"ModalBoxCloseButton",
"ModalBoxTitle",
"ModalBoxDescription",
];
for name in &internals {
assert!(
!tree
.edges
.iter()
.any(|e| e.parent == *name || e.child == *name),
"No edges should reference internal node {}. Edges: {:?}",
name,
tree.edges
);
}
}
fn bem_edge(parent: &str, child: &str) -> crate::sd_types::CompositionEdge {
crate::sd_types::CompositionEdge {
parent: parent.into(),
child: child.into(),
relationship: crate::sd_types::ChildRelationship::DirectChild,
required: false,
bem_evidence: Some(format!(
"BEM element fallback: {} is a BEM element of root's block",
child
)),
strength: crate::sd_types::EdgeStrength::Allowed,
prop_name: None,
}
}
fn non_bem_edge(
parent: &str,
child: &str,
strength: crate::sd_types::EdgeStrength,
) -> crate::sd_types::CompositionEdge {
crate::sd_types::CompositionEdge {
parent: parent.into(),
child: child.into(),
relationship: crate::sd_types::ChildRelationship::DirectChild,
required: strength == crate::sd_types::EdgeStrength::Required,
bem_evidence: Some("CSS descendant: . .child".into()),
strength,
prop_name: None,
}
}
fn wrapper_profile() -> ComponentSourceProfile {
ComponentSourceProfile {
has_children_prop: true,
children_slot_path: vec!["div".into()],
..Default::default()
}
}
#[test]
fn test_exclusive_wrapper_skipped_with_single_bem_child() {
let tree = CompositionTree {
root: "ClipboardCopy".into(),
family_members: vec!["ClipboardCopy".into(), "ClipboardCopyAction".into()],
edges: vec![bem_edge("ClipboardCopy", "ClipboardCopyAction")],
};
let mut profiles = HashMap::new();
profiles.insert("ClipboardCopyAction".to_string(), wrapper_profile());
let checks = generate_conformance_checks("ClipboardCopy", &tree, &profiles);
assert!(
!checks
.iter()
.any(|c| matches!(&c.check_type, ConformanceCheckType::ExclusiveWrapper { .. })),
"Single BEM child should not trigger ExclusiveWrapper"
);
}
#[test]
fn test_exclusive_wrapper_skipped_with_non_bem_children() {
use crate::sd_types::EdgeStrength;
let tree = CompositionTree {
root: "Toolbar".into(),
family_members: vec![
"Toolbar".into(),
"ToolbarContent".into(),
"ToolbarExpandIconWrapper".into(),
],
edges: vec![
non_bem_edge("Toolbar", "ToolbarContent", EdgeStrength::Allowed),
bem_edge("Toolbar", "ToolbarExpandIconWrapper"),
],
};
let mut profiles = HashMap::new();
profiles.insert("ToolbarExpandIconWrapper".to_string(), wrapper_profile());
let checks = generate_conformance_checks("Toolbar", &tree, &profiles);
assert!(
!checks
.iter()
.any(|c| matches!(&c.check_type, ConformanceCheckType::ExclusiveWrapper { .. })),
"Non-BEM direct children should prevent ExclusiveWrapper"
);
}
#[test]
fn test_exclusive_wrapper_kept_for_valid_wrapper_family() {
let tree = CompositionTree {
root: "ActionList".into(),
family_members: vec![
"ActionList".into(),
"ActionListGroup".into(),
"ActionListItem".into(),
],
edges: vec![
bem_edge("ActionList", "ActionListGroup"),
bem_edge("ActionList", "ActionListItem"),
],
};
let mut profiles = HashMap::new();
profiles.insert("ActionListItem".to_string(), wrapper_profile());
let checks = generate_conformance_checks("ActionList", &tree, &profiles);
let ew = checks
.iter()
.find(|c| matches!(&c.check_type, ConformanceCheckType::ExclusiveWrapper { .. }));
assert!(
ew.is_some(),
"Genuine wrapper family with >=2 BEM children should produce ExclusiveWrapper"
);
if let ConformanceCheckType::ExclusiveWrapper {
allowed_children, ..
} = &ew.unwrap().check_type
{
assert!(
allowed_children.contains(&"ActionListGroup".to_string()),
"Allowed set should include ActionListGroup"
);
assert!(
allowed_children.contains(&"ActionListItem".to_string()),
"Allowed set should include ActionListItem"
);
}
}
#[test]
fn test_ouia_extends_enrichment_chain() {
let tab_action_source = r#"
import * as React from 'react';
import { css } from '@patternfly/react-styles';
import styles from '@patternfly/react-styles/css/components/Tabs/tabs';
import { Button } from '../Button';
import { getOUIAProps, OUIAProps } from '../../helpers';
export interface TabActionProps extends Omit<React.HTMLProps<HTMLButtonElement>, 'ref' | 'type' | 'size'>, OUIAProps {
children?: React.ReactNode;
className?: string;
onClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
isDisabled?: boolean;
'aria-label'?: string;
innerRef?: React.Ref<any>;
}
"#;
let profile = crate::source_profile::extract_profile(
"TabAction",
"packages/react-core/src/components/Tabs/TabAction.tsx",
tab_action_source,
);
assert!(
profile.extends_props.contains(&"OUIAProps".to_string()),
"TabAction extends_props should contain OUIAProps, got: {:?}",
profile.extends_props
);
let import_source = find_import_for_type(tab_action_source, "OUIAProps");
assert_eq!(
import_source.as_deref(),
Some("../../helpers"),
"Should find OUIAProps import from '../../helpers'"
);
let imports = parse_import_sources(
tab_action_source,
"packages/react-core/src/components/Tabs/TabAction.tsx",
);
let resolved = resolve_relative_import(
"packages/react-core/src/components/Tabs/TabAction.tsx",
"../../helpers",
&imports,
);
assert_eq!(
resolved.as_deref(),
Some("packages/react-core/src/helpers"),
"Should resolve to packages/react-core/src/helpers"
);
let ouia_source = r#"
import { useMemo } from 'react';
type OuiaId = number | string;
export interface OUIAProps {
ouiaId?: OuiaId;
ouiaSafe?: boolean;
}
export function getOUIAProps(componentType: string, id: OuiaId, ouiaSafe: boolean = true) {
return {};
}
"#;
let ouia_props = extract_interface_props(ouia_source, "OUIAProps");
assert_eq!(
ouia_props,
Some(vec!["ouiaId".to_string(), "ouiaSafe".to_string()]),
"Should extract ouiaId and ouiaSafe from OUIAProps interface"
);
let barrel_source = r#"
export * from './constants';
export * from './OUIA/ouia';
export * from './util';
"#;
let reexport_sources = find_reexport_sources(
barrel_source,
"OUIAProps",
"packages/react-core/src/helpers/index.ts",
);
assert!(
!reexport_sources.is_empty(),
"Should find re-export sources for OUIAProps from barrel file"
);
assert!(
reexport_sources.iter().any(|p| p.contains("OUIA/ouia")),
"Re-export sources should include OUIA/ouia path, got: {:?}",
reexport_sources
);
assert!(
!profile.all_props.contains("ouiaId"),
"Before enrichment, all_props should not contain ouiaId. Got: {:?}",
profile.all_props
);
assert!(
!profile.all_props.contains("ouiaSafe"),
"Before enrichment, all_props should not contain ouiaSafe"
);
}
}