use crate::types::{
ApiSurface, BehavioralChangeKind, BodyAnalysisResult, BreakingVerdict, Caller, ChangeSubject,
ChangedFunction, EvidenceType, ExpectedChild, FunctionSpec, Reference, SdPipelineResult,
StructuralChange, StructuralChangeType, Symbol, SymbolKind, TestDiff, TestFile, Visibility,
};
use anyhow::Result;
use serde::{de::DeserializeOwned, Serialize};
use std::collections::{BTreeSet, HashMap};
use std::fmt::Debug;
use std::path::Path;
pub trait BehaviorAnalyzer {
fn infer_spec(&self, function_body: &str, signature: &str) -> Result<FunctionSpec>;
fn infer_spec_with_test_context(
&self,
function_body: &str,
signature: &str,
test_context: &TestDiff,
) -> Result<FunctionSpec>;
fn specs_are_breaking(&self, old: &FunctionSpec, new: &FunctionSpec)
-> Result<BreakingVerdict>;
fn check_propagation(
&self,
caller_body: &str,
caller_signature: &str,
callee_name: &str,
evidence_description: &str,
) -> Result<bool>;
}
pub trait LanguageSemantics {
fn is_member_addition_breaking(&self, container: &Symbol, member: &Symbol) -> bool;
fn same_family(&self, a: &Symbol, b: &Symbol) -> bool;
fn same_identity(&self, a: &Symbol, b: &Symbol) -> bool;
fn visibility_rank(&self, v: Visibility) -> u8;
fn parse_union_values(&self, _type_str: &str) -> Option<BTreeSet<String>> {
None
}
fn is_async_wrapper(&self, _type_str: &str) -> bool {
false
}
fn format_import_change(&self, symbol: &str, old_path: &str, new_path: &str) -> String {
format!(
"replace import of `{}` from `{}` with `{}`",
symbol, old_path, new_path,
)
}
fn post_process(&self, _changes: &mut Vec<StructuralChange>) {}
fn hierarchy(&self) -> Option<&dyn HierarchySemantics> {
None
}
fn renames(&self) -> Option<&dyn RenameSemantics> {
None
}
fn body_analyzer(&self) -> Option<&dyn BodyAnalysisSemantics> {
None
}
}
pub trait HierarchySemantics {
fn family_source_paths(&self, repo: &Path, git_ref: &str, family_name: &str) -> Vec<String>;
fn family_name_from_symbols(&self, symbols: &[&Symbol]) -> Option<String>;
fn cross_family_relationships(
&self,
repo: &Path,
git_ref: &str,
) -> Vec<(String, String, String)>;
fn related_family_content(
&self,
repo: &Path,
git_ref: &str,
family_name: &str,
relationship_names: &[String],
) -> Option<String>;
fn is_hierarchy_candidate(&self, sym: &Symbol) -> bool;
fn min_components_for_hierarchy(&self) -> usize {
2
}
fn compute_deterministic_hierarchy(
&self,
new_surface: &ApiSurface,
structural_changes: &[StructuralChange],
) -> HashMap<String, HashMap<String, Vec<ExpectedChild>>> {
use std::collections::{BTreeMap, HashSet};
let mut families: HashMap<String, Vec<&Symbol>> = HashMap::new();
for sym in &new_surface.symbols {
if !self.is_hierarchy_candidate(sym) {
continue;
}
if let Some(family) = self.family_name_from_symbols(&[sym]) {
families.entry(family).or_default().push(sym);
}
}
let mut iface_extends: HashMap<&str, &str> = HashMap::new();
for sym in &new_surface.symbols {
if sym.kind == SymbolKind::Interface {
if let Some(ext) = &sym.extends {
iface_extends.insert(&sym.name, ext.as_str());
}
}
}
let iface_names: HashSet<&str> = new_surface
.symbols
.iter()
.filter(|s| s.kind == SymbolKind::Interface)
.map(|s| s.name.as_str())
.collect();
let mut props_to_component: HashMap<String, &str> = HashMap::new();
for sym in &new_surface.symbols {
if !self.is_hierarchy_candidate(sym) {
continue;
}
let props_name = format!("{}Props", sym.name);
if iface_names.contains(props_name.as_str()) {
props_to_component.insert(props_name, &sym.name);
}
}
let mut removed_props_by_parent: HashMap<String, HashSet<String>> = HashMap::new();
for change in structural_changes {
if let StructuralChangeType::Removed(ChangeSubject::Member { name, .. }) =
&change.change_type
{
let parent = if let Some((p, _)) = change.symbol.rsplit_once('.') {
p.strip_suffix("Props").unwrap_or(p).to_string()
} else {
change
.symbol
.strip_suffix("Props")
.unwrap_or(&change.symbol)
.to_string()
};
removed_props_by_parent
.entry(parent)
.or_default()
.insert(name.clone());
}
}
let mut absorption_children: HashMap<String, BTreeMap<String, Vec<String>>> =
HashMap::new();
for members in families.values() {
for parent in members.iter() {
let removed = match removed_props_by_parent.get(&parent.name) {
Some(r) if !r.is_empty() => r,
_ => continue,
};
for candidate in members.iter() {
if candidate.name == parent.name {
continue;
}
let candidate_props: HashSet<&str> =
candidate.members.iter().map(|m| m.name.as_str()).collect();
let props_iface_name = format!("{}Props", candidate.name);
let iface_props: HashSet<&str> = new_surface
.symbols
.iter()
.find(|s| s.name == props_iface_name && s.kind == SymbolKind::Interface)
.map(|s| s.members.iter().map(|m| m.name.as_str()).collect())
.unwrap_or_default();
let all_candidate_props: HashSet<&str> =
candidate_props.union(&iface_props).copied().collect();
let absorbed: Vec<String> = removed
.iter()
.filter(|prop| all_candidate_props.contains(prop.as_str()))
.cloned()
.collect();
if !absorbed.is_empty() {
absorption_children
.entry(parent.name.clone())
.or_default()
.insert(candidate.name.clone(), absorbed);
}
}
}
}
let mut extends_map: HashMap<&str, &str> = HashMap::new();
for members in families.values() {
for sym in members {
let props_name = format!("{}Props", sym.name);
if let Some(ext_iface) = iface_extends.get(props_name.as_str()) {
let ext_clean = ext_iface
.strip_prefix("Omit<")
.and_then(|s| s.split(',').next())
.unwrap_or(ext_iface);
if let Some(ext_component) = props_to_component.get(ext_clean) {
let ext_family = self.family_name_from_symbols(&[
new_surface
.symbols
.iter()
.find(|s| s.name.as_str() == *ext_component)
.unwrap_or(sym),
]);
let own_family = self.family_name_from_symbols(&[sym]);
if ext_family != own_family {
extends_map.insert(&sym.name, ext_component);
}
}
}
}
}
let mut result: HashMap<String, HashMap<String, Vec<ExpectedChild>>> = HashMap::new();
for (family_name, members) in &families {
let member_names: HashSet<&str> = members.iter().map(|s| s.name.as_str()).collect();
let mut family_hierarchy: HashMap<String, Vec<ExpectedChild>> = HashMap::new();
let mut renders_family: HashMap<&str, HashSet<&str>> = HashMap::new();
for sym in members {
let family_renders: HashSet<&str> = sym
.rendered_components
.iter()
.filter(|r| {
member_names.contains(r.as_str()) && r.as_str() != sym.name.as_str()
})
.map(|r| r.as_str())
.collect();
if !family_renders.is_empty() {
renders_family.insert(&sym.name, family_renders);
}
}
for parent in members.iter() {
let mut children: BTreeMap<&str, ExpectedChild> = BTreeMap::new();
if let Some(absorbed) = absorption_children.get(&parent.name) {
for child_name in absorbed.keys() {
if !member_names.contains(child_name.as_str()) {
continue;
}
let parent_renders = renders_family.get(parent.name.as_str());
let is_rendered = parent_renders
.map(|r| r.contains(child_name.as_str()))
.unwrap_or(false);
let child = if is_rendered {
ExpectedChild {
name: child_name.clone(),
required: false,
mechanism: "prop".to_string(),
prop_name: None,
}
} else {
ExpectedChild::new(child_name, false)
};
children.insert(child_name.as_str(), child);
}
}
if let Some(ext_parent) = extends_map.get(parent.name.as_str()) {
let renders_ext_parent = parent
.rendered_components
.iter()
.any(|r| r.as_str() == *ext_parent);
let ext_parent_sym = new_surface
.symbols
.iter()
.find(|s| s.name.as_str() == *ext_parent);
let ext_parent_is_container = ext_parent_sym
.map(|ep| {
let ep_family = self.family_name_from_symbols(&[ep]);
ep.rendered_components.iter().any(|rc| {
new_surface
.symbols
.iter()
.filter(|s| self.is_hierarchy_candidate(s))
.any(|s| {
s.name.as_str() == rc.as_str()
&& self.family_name_from_symbols(&[s]) == ep_family
})
})
})
.unwrap_or(false);
if !renders_ext_parent || !ext_parent_is_container {
} else if let Some(ext_sym) = ext_parent_sym {
for candidate in members.iter() {
if candidate.name == parent.name {
continue;
}
if children.contains_key(candidate.name.as_str()) {
continue; }
if let Some(ext_child) = extends_map.get(candidate.name.as_str()) {
let ext_renders_child =
ext_sym.rendered_components.contains(&ext_child.to_string());
if !ext_renders_child {
let ext_child_sym = new_surface
.symbols
.iter()
.find(|s| s.name.as_str() == *ext_child);
let ext_child_is_container = ext_child_sym
.map(|ec| {
let ec_family = self.family_name_from_symbols(&[ec]);
ec.rendered_components.iter().any(|rc| {
new_surface
.symbols
.iter()
.filter(|s| self.is_hierarchy_candidate(s))
.any(|s| {
s.name.as_str() == rc.as_str()
&& self.family_name_from_symbols(&[s])
== ec_family
})
})
})
.unwrap_or(false);
if !ext_child_is_container {
children.insert(
&candidate.name,
ExpectedChild::new(&candidate.name, false),
);
}
}
}
}
} }
if !children.is_empty() {
family_hierarchy.insert(parent.name.clone(), children.into_values().collect());
}
}
if !family_hierarchy.is_empty() {
result.insert(family_name.clone(), family_hierarchy);
}
}
result
}
}
pub trait RenameSemantics {
fn sample_removed_constants<'a>(
&self,
removed: &[&'a str],
_added: &[&'a str],
) -> Vec<&'a str> {
removed.iter().take(30).copied().collect()
}
fn sample_added_constants<'a>(&self, _removed: &[&'a str], added: &[&'a str]) -> Vec<&'a str> {
added.iter().take(30).copied().collect()
}
fn min_removed_for_constant_inference(&self) -> usize {
50
}
fn min_removed_for_interface_inference(&self) -> usize {
2
}
}
pub trait BodyAnalysisSemantics {
fn analyze_changed_body(
&self,
old_body: &str,
new_body: &str,
func_name: &str,
file_path: &str,
) -> Vec<BodyAnalysisResult>;
}
pub trait MessageFormatter {
fn describe(&self, change: &StructuralChange) -> String;
}
pub trait Language: LanguageSemantics + MessageFormatter + Send + Sync + 'static {
type Category: Debug + Clone + Serialize + DeserializeOwned + Eq + std::hash::Hash + Send + Sync;
type ManifestChangeType: Debug
+ Clone
+ Serialize
+ DeserializeOwned
+ Eq
+ PartialEq
+ Send
+ Sync;
type Evidence: Debug + Clone + Serialize + DeserializeOwned + Send + Sync;
type ReportData: Debug + Clone + Serialize + DeserializeOwned + Send + Sync;
const RENAMEABLE_SYMBOL_KINDS: &'static [SymbolKind];
const NAME: &'static str;
const MANIFEST_FILES: &'static [&'static str];
const SOURCE_FILE_PATTERNS: &'static [&'static str];
fn extract(&self, repo: &Path, git_ref: &str) -> Result<ApiSurface>;
fn parse_changed_functions(
&self,
repo: &Path,
from_ref: &str,
to_ref: &str,
) -> Result<Vec<ChangedFunction>>;
fn find_callers(&self, file: &Path, symbol_name: &str) -> Result<Vec<Caller>>;
fn find_references(&self, file: &Path, symbol_name: &str) -> Result<Vec<Reference>>;
fn find_tests(&self, repo: &Path, source_file: &Path) -> Result<Vec<TestFile>>;
fn diff_test_assertions(
&self,
repo: &Path,
test_file: &TestFile,
from_ref: &str,
to_ref: &str,
) -> Result<TestDiff>;
fn diff_manifest_content(old: &str, new: &str) -> Vec<crate::types::ManifestChange<Self>>
where
Self: Sized;
fn should_exclude_from_analysis(path: &Path) -> bool;
fn build_report(
results: &crate::types::AnalysisResult<Self>,
repo: &Path,
from_ref: &str,
to_ref: &str,
) -> crate::types::AnalysisReport<Self>
where
Self: Sized;
fn behavioral_change_kind(&self, _evidence_type: &EvidenceType) -> BehavioralChangeKind {
BehavioralChangeKind::Function
}
fn extract_referenced_symbols(&self, _description: &str) -> Vec<String> {
vec![]
}
fn display_name(&self, qualified_name: &str) -> String {
qualified_name.to_string()
}
fn run_source_diff(
&self,
_repo: &Path,
_from_ref: &str,
_to_ref: &str,
_dep_css_dir: Option<&Path>,
) -> Result<SdPipelineResult> {
Ok(SdPipelineResult::default())
}
}
pub fn diff_surfaces_with_semantics(
old: &ApiSurface,
new: &ApiSurface,
semantics: &dyn LanguageSemantics,
) -> Vec<StructuralChange> {
crate::diff::diff_surfaces_with_semantics(old, new, semantics)
}
pub fn diff_surfaces(old: &ApiSurface, new: &ApiSurface) -> Vec<StructuralChange> {
crate::diff::diff_surfaces(old, new)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{SymbolKind, Visibility};
use std::path::PathBuf;
struct TestHierarchy;
impl HierarchySemantics for TestHierarchy {
fn family_source_paths(
&self,
_repo: &Path,
_git_ref: &str,
_family_name: &str,
) -> Vec<String> {
Vec::new()
}
fn family_name_from_symbols(&self, symbols: &[&Symbol]) -> Option<String> {
symbols.first().and_then(|s| {
s.file
.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
})
}
fn cross_family_relationships(
&self,
_repo: &Path,
_git_ref: &str,
) -> Vec<(String, String, String)> {
Vec::new()
}
fn related_family_content(
&self,
_repo: &Path,
_git_ref: &str,
_family_name: &str,
_relationship_names: &[String],
) -> Option<String> {
None
}
fn is_hierarchy_candidate(&self, sym: &Symbol) -> bool {
matches!(
sym.kind,
SymbolKind::Variable | SymbolKind::Function | SymbolKind::Constant
) && sym.name.starts_with(|c: char| c.is_ascii_uppercase())
}
}
fn make_component(name: &str, family: &str, rendered: Vec<&str>) -> Symbol {
let mut sym = Symbol::new(
name,
format!("src/components/{}/{}.{}", family, name, name),
SymbolKind::Variable,
Visibility::Exported,
PathBuf::from(format!("src/components/{}/{}.d.ts", family, name)),
1,
);
sym.rendered_components = rendered.into_iter().map(|s| s.to_string()).collect();
sym
}
fn make_interface(
name: &str,
family: &str,
extends: Option<&str>,
members: Vec<&str>,
) -> Symbol {
let mut sym = Symbol::new(
name,
format!("src/components/{}/{}.{}", family, name, name),
SymbolKind::Interface,
Visibility::Exported,
PathBuf::from(format!("src/components/{}/{}.d.ts", family, name)),
1,
);
sym.extends = extends.map(|e| e.to_string());
sym.members = members
.into_iter()
.map(|m| {
Symbol::new(
m,
format!("{}.{}", name, m),
SymbolKind::Variable,
Visibility::Exported,
PathBuf::from(format!("src/components/{}/{}.d.ts", family, name)),
1,
)
})
.collect();
sym
}
fn removed_member(parent: &str, member: &str) -> StructuralChange {
StructuralChange {
symbol: format!("{}.{}", parent, member),
qualified_name: format!("src/components/X/{}.{}", parent, member),
kind: SymbolKind::Interface,
package: None,
change_type: StructuralChangeType::Removed(ChangeSubject::Member {
name: member.to_string(),
kind: SymbolKind::Variable,
}),
before: None,
after: None,
description: format!("property `{}` was removed", member),
is_breaking: true,
impact: None,
migration_target: None,
}
}
fn child_names(
result: &HashMap<String, HashMap<String, Vec<ExpectedChild>>>,
family: &str,
component: &str,
) -> Vec<String> {
result
.get(family)
.and_then(|f| f.get(component))
.map(|children| children.iter().map(|c| c.name.clone()).collect())
.unwrap_or_default()
}
fn child_mechanism(
result: &HashMap<String, HashMap<String, Vec<ExpectedChild>>>,
family: &str,
parent: &str,
child: &str,
) -> Option<String> {
result
.get(family)
.and_then(|f| f.get(parent))
.and_then(|children| children.iter().find(|c| c.name == child))
.map(|c| c.mechanism.clone())
}
fn has_entry(
result: &HashMap<String, HashMap<String, Vec<ExpectedChild>>>,
family: &str,
component: &str,
) -> bool {
result
.get(family)
.map(|f| f.contains_key(component))
.unwrap_or(false)
}
#[test]
fn signal1_modal_v6_absorption() {
let h = TestHierarchy;
let new_surface = ApiSurface {
symbols: vec![
make_component("Modal", "Modal", vec!["ModalContent"]),
make_component(
"ModalHeader",
"Modal",
vec!["ModalBoxDescription", "ModalBoxTitle"],
),
make_component("ModalBody", "Modal", vec![]),
make_component("ModalFooter", "Modal", vec![]),
make_interface(
"ModalProps",
"Modal",
None,
vec!["children", "className", "isOpen", "onClose", "variant"],
),
make_interface(
"ModalHeaderProps",
"Modal",
None,
vec![
"children",
"className",
"title",
"description",
"help",
"titleIconVariant",
"titleScreenReaderText",
],
),
make_interface(
"ModalBodyProps",
"Modal",
None,
vec!["children", "className", "role"],
),
make_interface(
"ModalFooterProps",
"Modal",
None,
vec!["children", "className"],
),
],
};
let changes = vec![
removed_member("ModalProps", "title"),
removed_member("ModalProps", "description"),
removed_member("ModalProps", "help"),
removed_member("ModalProps", "titleIconVariant"),
removed_member("ModalProps", "titleLabel"),
removed_member("ModalProps", "bodyAriaRole"),
removed_member("ModalProps", "actions"),
removed_member("ModalProps", "footer"),
removed_member("ModalProps", "header"),
removed_member("ModalProps", "showClose"),
removed_member("ModalProps", "hasNoBodyWrapper"),
];
let result = h.compute_deterministic_hierarchy(&new_surface, &changes);
assert!(
has_entry(&result, "Modal", "Modal"),
"Modal should be a parent"
);
let modal_children = child_names(&result, "Modal", "Modal");
assert!(
modal_children.contains(&"ModalHeader".to_string()),
"ModalHeader absorbed props from Modal"
);
assert!(
modal_children.contains(&"ModalHeader".to_string()),
"ModalHeader absorbed title/description/help/titleIconVariant from Modal"
);
assert_eq!(
child_mechanism(&result, "Modal", "Modal", "ModalHeader"),
Some("child".to_string()),
"ModalHeader is a direct JSX child (not rendered internally)"
);
}
#[test]
fn signal2_dropdown_cross_family_extends() {
let h = TestHierarchy;
let new_surface = ApiSurface {
symbols: vec![
make_component(
"Dropdown",
"Dropdown",
vec!["Menu", "MenuContent", "Popper"],
),
make_component("DropdownGroup", "Dropdown", vec!["MenuGroup"]),
make_component("DropdownItem", "Dropdown", vec!["MenuItem"]),
make_component("DropdownList", "Dropdown", vec!["MenuList"]),
make_interface(
"DropdownProps",
"Dropdown",
Some("MenuProps"),
vec!["children", "className", "toggle", "isOpen", "onSelect"],
),
make_interface(
"DropdownGroupProps",
"Dropdown",
Some("MenuGroupProps"),
vec!["children", "label"],
),
make_interface(
"DropdownItemProps",
"Dropdown",
Some("MenuItemProps"),
vec!["children", "value", "isDisabled"],
),
make_interface(
"DropdownListProps",
"Dropdown",
Some("MenuListProps"),
vec!["children"],
),
make_component("Menu", "Menu", vec!["MenuContext"]),
make_component("MenuContext", "Menu", vec![]),
make_component("MenuContent", "Menu", vec![]),
make_component("MenuList", "Menu", vec![]),
make_component("MenuItem", "Menu", vec![]),
make_component("MenuGroup", "Menu", vec!["MenuList"]),
make_interface(
"MenuProps",
"Menu",
None,
vec!["children", "className", "onSelect"],
),
make_interface("MenuListProps", "Menu", None, vec!["children"]),
make_interface("MenuItemProps", "Menu", None, vec!["children", "value"]),
make_interface("MenuGroupProps", "Menu", None, vec!["children", "label"]),
],
};
let result = h.compute_deterministic_hierarchy(&new_surface, &[]);
assert!(
has_entry(&result, "Dropdown", "Dropdown"),
"Dropdown should be a parent via extends mapping"
);
let dropdown_children = child_names(&result, "Dropdown", "Dropdown");
assert!(
dropdown_children.contains(&"DropdownItem".to_string()),
"DropdownItem wraps MenuItem (leaf) → consumer child of Dropdown"
);
assert!(
dropdown_children.contains(&"DropdownList".to_string()),
"DropdownList wraps MenuList (leaf) → consumer child of Dropdown"
);
assert!(
!dropdown_children.contains(&"DropdownGroup".to_string()),
"DropdownGroup wraps MenuGroup (container) → not a direct child of Dropdown"
);
assert!(
has_entry(&result, "Dropdown", "DropdownGroup"),
"DropdownGroup should be a parent"
);
let group_children = child_names(&result, "Dropdown", "DropdownGroup");
assert!(
group_children.contains(&"DropdownItem".to_string()),
"DropdownItem is a child of DropdownGroup"
);
assert!(
!group_children.contains(&"Dropdown".to_string()),
"Dropdown is a container, not a child of DropdownGroup"
);
assert!(
!has_entry(&result, "Dropdown", "DropdownList"),
"DropdownList should NOT be a parent (MenuList is not a container)"
);
assert!(
!has_entry(&result, "Dropdown", "DropdownItem"),
"DropdownItem should NOT be a parent"
);
}
#[test]
fn signal3_rendered_internally_means_prop_passed() {
let h = TestHierarchy;
let new_surface = ApiSurface {
symbols: vec![
make_component(
"FormFieldGroup",
"Form",
vec!["FormFieldGroupHeader"], ),
make_component("FormFieldGroupHeader", "Form", vec![]),
make_component("FormGroup", "Form", vec![]),
make_interface(
"FormFieldGroupProps",
"Form",
None,
vec!["children", "header"],
),
make_interface(
"FormFieldGroupHeaderProps",
"Form",
None,
vec!["titleText", "titleDescription"],
),
],
};
let changes = vec![
removed_member("FormFieldGroupProps", "titleText"),
removed_member("FormFieldGroupProps", "titleDescription"),
];
let result = h.compute_deterministic_hierarchy(&new_surface, &changes);
assert!(has_entry(&result, "Form", "FormFieldGroup"));
let children = child_names(&result, "Form", "FormFieldGroup");
assert!(
children.contains(&"FormFieldGroupHeader".to_string()),
"FormFieldGroupHeader absorbed props from FormFieldGroup"
);
assert_eq!(
child_mechanism(&result, "Form", "FormFieldGroup", "FormFieldGroupHeader"),
Some("prop".to_string()),
"FormFieldGroupHeader is rendered internally → prop-passed"
);
}
#[test]
fn masthead_all_leaves() {
let h = TestHierarchy;
let surface = ApiSurface {
symbols: vec![
make_component("Masthead", "Masthead", vec![]),
make_component("MastheadBrand", "Masthead", vec![]),
make_component("MastheadContent", "Masthead", vec![]),
make_component("MastheadLogo", "Masthead", vec![]),
make_component("MastheadMain", "Masthead", vec![]),
make_component("MastheadToggle", "Masthead", vec![]),
],
};
let result = h.compute_deterministic_hierarchy(&surface, &[]);
assert!(
!result.contains_key("Masthead"),
"Masthead: all components are leaves (div wrappers)"
);
}
#[test]
fn no_signals_empty_hierarchy() {
let h = TestHierarchy;
let surface = ApiSurface {
symbols: vec![
make_component("Modal", "Modal", vec![]),
make_component("ModalHeader", "Modal", vec![]),
],
};
let result = h.compute_deterministic_hierarchy(&surface, &[]);
assert!(result.is_empty(), "no signals → no hierarchy");
}
#[test]
fn interfaces_not_hierarchy_candidates() {
let h = TestHierarchy;
let surface = ApiSurface {
symbols: vec![
make_component("Modal", "Modal", vec![]),
make_component("ModalBody", "Modal", vec![]),
make_interface("ModalProps", "Modal", None, vec!["children"]),
],
};
let changes = vec![removed_member("ModalProps", "title")];
let result = h.compute_deterministic_hierarchy(&surface, &changes);
for family in result.values() {
for children in family.values() {
for child in children {
assert_ne!(
child.name, "ModalProps",
"Interfaces should not be hierarchy candidates"
);
}
}
}
}
}