Skip to main content

semver_analyzer_ts/
language.rs

1//! TypeScript `Language` trait implementation.
2//!
3//! Provides all TypeScript/React-specific semantic rules, message formatting,
4//! and associated types for the multi-language architecture.
5//!
6//! This module extracts language-specific logic that currently lives in
7//! `core/diff/compare.rs`, `core/diff/helpers.rs`, `core/diff/migration.rs`,
8//! and `core/diff/mod.rs` into a trait implementation that the diff engine
9//! can call through the `LanguageSemantics` and `MessageFormatter` traits.
10
11use anyhow::Result;
12use semver_analyzer_core::{
13    AnalysisReport, AnalysisResult, ApiSurface, BehavioralChangeKind, BodyAnalysisResult,
14    BodyAnalysisSemantics, Caller, ChangedFunction, EvidenceType, ExpectedChild,
15    ExtendedAnalysisParams, HierarchySemantics, Language, LanguageSemantics, ManifestChange,
16    MessageFormatter, Reference, RenameSemantics, StructuralChange, StructuralChangeType, Symbol,
17    SymbolKind, TestDiff, TestFile, Visibility,
18};
19use serde::{Deserialize, Serialize};
20use std::collections::{BTreeSet, HashSet};
21use std::path::Path;
22use std::sync::Arc;
23
24use crate::extensions::TsAnalysisExtensions;
25use crate::TsSymbolData;
26
27// ── TypeScript language type ────────────────────────────────────────────
28
29/// The TypeScript language implementation.
30#[derive(Debug, Clone)]
31pub struct TypeScript {
32    build_command: Option<String>,
33}
34
35impl TypeScript {
36    pub fn new(build_command: Option<String>) -> Self {
37        Self { build_command }
38    }
39}
40
41impl Default for TypeScript {
42    fn default() -> Self {
43        Self {
44            build_command: Some("yarn build".to_string()),
45        }
46    }
47}
48
49// ── Associated types ────────────────────────────────────────────────────
50
51/// Behavioral change categories for TypeScript/React analysis.
52#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum TsCategory {
55    /// Changed element types, wrapper elements, component nesting.
56    DomStructure,
57    /// CSS class name renames, removals, changed application logic.
58    CssClass,
59    /// CSS custom property (variable) renames or removals.
60    CssVariable,
61    /// ARIA attribute changes, role changes, keyboard navigation.
62    Accessibility,
63    /// Changed default prop/parameter values.
64    DefaultValue,
65    /// Changed conditional logic, return values, event handling.
66    LogicChange,
67    /// Changed data-* attributes (data-testid, data-ouia-*, etc.).
68    DataAttribute,
69    /// General render output change.
70    RenderOutput,
71}
72
73/// Manifest change types for npm/package.json.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum TsManifestChangeType {
77    EntryPointChanged,
78    ExportsEntryRemoved,
79    ExportsEntryAdded,
80    ExportsConditionRemoved,
81    ModuleSystemChanged,
82    PeerDependencyAdded,
83    PeerDependencyRemoved,
84    PeerDependencyRangeChanged,
85    EngineConstraintChanged,
86    BinEntryRemoved,
87}
88
89/// Evidence data for TypeScript behavioral changes.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(tag = "type", rename_all = "snake_case")]
92pub enum TsEvidence {
93    /// Test assertions changed.
94    TestDelta {
95        removed_assertions: Vec<String>,
96        added_assertions: Vec<String>,
97    },
98    /// Deterministic JSX AST diff.
99    JsxDiff {
100        element_before: Option<String>,
101        element_after: Option<String>,
102        change_description: String,
103    },
104    /// Deterministic CSS reference scan.
105    CssScan { change_description: String },
106    /// LLM-based analysis (with or without test context).
107    LlmAnalysis {
108        has_test_context: bool,
109        spec_summary: String,
110    },
111}
112
113/// TypeScript-specific report data carried on each `TypeSummary`.
114///
115/// Contains React/JSX-specific analysis results: discovered child
116/// components with absorbed members, and expected composition
117/// hierarchy children from LLM inference.
118///
119/// Flattened into the parent `TypeSummary` JSON via `#[serde(flatten)]`
120/// for backward compatibility — fields appear at the top level.
121#[derive(Debug, Clone, Default, Serialize, Deserialize)]
122pub struct TsReportData {
123    /// Discovered child/sibling components (e.g., ModalHeader added
124    /// alongside Modal being modified).
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub child_components: Vec<ChildComponent>,
127
128    /// Expected direct children of this component, derived from LLM
129    /// hierarchy inference on the component family's source code.
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub expected_children: Vec<ExpectedChild>,
132}
133
134/// A child or sibling component discovered during TypeScript analysis.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ChildComponent {
137    /// Component name (e.g., "ModalHeader").
138    pub name: String,
139    /// Whether this component was added or modified.
140    pub status: ChildComponentStatus,
141    /// Known members on this child component (from the new surface AST).
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub known_members: Vec<String>,
144    /// Members that were removed from the parent and match members on this
145    /// child (by name). Populated from AST member comparison.
146    #[serde(default, skip_serializing_if = "Vec::is_empty")]
147    pub absorbed_members: Vec<String>,
148}
149
150/// Status of a child/sibling component.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum ChildComponentStatus {
154    /// Newly added in the new version.
155    Added,
156    /// Existed before but was modified.
157    Modified,
158}
159
160// ── LanguageSemantics ───────────────────────────────────────────────────
161
162impl LanguageSemantics<TsSymbolData> for TypeScript {
163    fn is_member_addition_breaking(
164        &self,
165        container: &Symbol<TsSymbolData>,
166        member: &Symbol<TsSymbolData>,
167    ) -> bool {
168        // TypeScript uses structural typing. Adding a required member to an
169        // interface or type alias breaks consumers because they must now
170        // provide it. Adding an optional member is non-breaking.
171        //
172        // For enums and classes, adding a member is never breaking.
173        match container.kind {
174            SymbolKind::Interface | SymbolKind::TypeAlias => {
175                let is_optional = member
176                    .signature
177                    .as_ref()
178                    .and_then(|s| s.parameters.first())
179                    .map(|p| p.optional)
180                    .unwrap_or(false);
181                !is_optional
182            }
183            _ => false,
184        }
185    }
186
187    fn same_family(&self, a: &Symbol<TsSymbolData>, b: &Symbol<TsSymbolData>) -> bool {
188        // React convention: components in the same directory are a family.
189        // E.g., components/Modal/Modal.tsx and components/Modal/ModalHeader.tsx
190        //
191        // We strip /deprecated/ and /next/ segments for canonical matching so
192        // that a symbol moving between deprecated/ and main/ paths is still
193        // considered the same family.
194        canonical_component_dir(&a.file.to_string_lossy())
195            == canonical_component_dir(&b.file.to_string_lossy())
196    }
197
198    fn same_identity(&self, a: &Symbol<TsSymbolData>, b: &Symbol<TsSymbolData>) -> bool {
199        // React convention: ButtonProps and Button are the same concept.
200        // Strip the "Props" suffix before comparing.
201        strip_props_suffix(&a.name) == strip_props_suffix(&b.name)
202    }
203
204    fn visibility_rank(&self, v: Visibility) -> u8 {
205        // TypeScript visibility ranking. Protected is treated the same as
206        // Internal for semver purposes (both are non-exported).
207        match v {
208            Visibility::Private => 0,
209            Visibility::Internal => 1,
210            Visibility::Protected => 1, // TS protected ≈ internal for semver
211            Visibility::Public => 2,
212            Visibility::Exported => 3,
213        }
214    }
215
216    fn parse_union_values(&self, type_str: &str) -> Option<BTreeSet<String>> {
217        // TypeScript string literal unions: 'primary' | 'secondary' | 'danger'
218        parse_ts_union_literals(type_str)
219    }
220
221    fn post_process(&self, changes: &mut Vec<StructuralChange>) {
222        // Deduplicate changes for symbols exported both by name and as
223        // `export default` (a TypeScript/JS-specific pattern).
224        dedup_default_exports(changes);
225    }
226
227    fn hierarchy(&self) -> Option<&dyn HierarchySemantics<TsSymbolData>> {
228        Some(self)
229    }
230
231    fn renames(&self) -> Option<&dyn RenameSemantics> {
232        Some(self)
233    }
234
235    fn body_analyzer(&self) -> Option<&dyn BodyAnalysisSemantics> {
236        Some(self)
237    }
238
239    fn primitive_type_names(&self) -> &[&str] {
240        &[
241            "string",
242            "number",
243            "boolean",
244            "void",
245            "null",
246            "undefined",
247            "never",
248            "any",
249            "unknown",
250        ]
251    }
252
253    fn is_async_wrapper(&self, type_str: &str) -> bool {
254        type_str.starts_with("Promise<")
255    }
256
257    fn format_import_change(&self, symbol: &str, old_path: &str, new_path: &str) -> String {
258        format!(
259            "replace `import {{ {} }} from '{}'` with `import {{ {} }} from '{}'`",
260            symbol, old_path, symbol, new_path,
261        )
262    }
263
264    fn should_skip_symbol(&self, sym: &Symbol<TsSymbolData>) -> bool {
265        // Star re-exports (`export * from './module'`) are barrel-file
266        // directives, not actual API symbols.
267        sym.name == "*"
268    }
269
270    fn member_label(&self) -> &'static str {
271        "props"
272    }
273
274    fn extract_rename_fallback_key(&self, sym: &Symbol<TsSymbolData>) -> Option<String> {
275        // Token `.d.ts` files have type annotations like:
276        //   { ["name"]: "--pf-v5-global--Color--dark-100"; ["value"]: "#151515"; ["var"]: "var(...)" }
277        // Extract the "value" field for CSS-value-based rename matching.
278        let return_type = sym.signature.as_ref()?.return_type.as_deref()?;
279        let value_start = return_type
280            .find("[\"value\"]")
281            .or_else(|| return_type.find("\"value\""))?;
282        let after_key = &return_type[value_start..];
283        let colon_pos = after_key.find(':')?;
284        let after_colon = &after_key[colon_pos + 1..];
285        let open_quote = after_colon.find('"')?;
286        let after_open = &after_colon[open_quote + 1..];
287        let close_quote = after_open.find('"')?;
288        let value = after_open[..close_quote].to_string();
289        if value.is_empty() {
290            None
291        } else {
292            Some(value)
293        }
294    }
295
296    fn canonical_name_for_relocation(&self, qualified_name: &str) -> String {
297        // Strip /deprecated/ and /next/ lifecycle segments so symbols
298        // moving between these directories are matched as relocations.
299        qualified_name
300            .replace("/deprecated/", "/")
301            .replace("/next/", "/")
302    }
303
304    fn classify_relocation(&self, old_qname: &str, new_qname: &str) -> Option<&'static str> {
305        let old_deprecated = old_qname.contains("/deprecated/");
306        let new_deprecated = new_qname.contains("/deprecated/");
307        let old_next = old_qname.contains("/next/");
308        let new_next = new_qname.contains("/next/");
309
310        match (old_deprecated, new_deprecated, old_next, new_next) {
311            (false, true, _, _) => Some("moved to deprecated"),
312            (true, false, _, _) => Some("promoted from deprecated"),
313            (_, _, true, false) => Some("promoted from next"),
314            (_, _, false, true) => Some("moved to next"),
315            _ => None,
316        }
317    }
318
319    fn derive_import_subpath(&self, package: Option<&str>, qualified_name: &str) -> String {
320        let base = package.unwrap_or("unknown");
321        if qualified_name.contains("/deprecated/") {
322            format!("{}/deprecated", base)
323        } else if qualified_name.contains("/next/") {
324            format!("{}/next", base)
325        } else {
326            base.to_string()
327        }
328    }
329}
330
331// ── MessageFormatter ────────────────────────────────────────────────────
332
333impl MessageFormatter for TypeScript {
334    fn describe(&self, change: &StructuralChange) -> String {
335        // For Phase 2, this matches on the current 37-variant StructuralChangeType.
336        // In Phase 4 when we collapse the enum, this will be updated to match
337        // on the new 5-variant StructuralChangeTypeV2 + ChangeSubject.
338        //
339        // The descriptions must produce identical output to the current inline
340        // description building in compare.rs and helpers.rs.
341        //
342        // For now, the descriptions are already built by the diff engine and
343        // stored on the StructuralChange. This method returns them as-is.
344        // In Phase 3, the diff engine will stop building descriptions and
345        // call this method instead.
346        change.description.clone()
347    }
348}
349
350// ── Language ────────────────────────────────────────────────────────────
351
352impl Language for TypeScript {
353    type SymbolData = TsSymbolData;
354    type Category = TsCategory;
355    type ManifestChangeType = TsManifestChangeType;
356    type Evidence = TsEvidence;
357    type ReportData = TsReportData;
358    type AnalysisExtensions = TsAnalysisExtensions;
359
360    const RENAMEABLE_SYMBOL_KINDS: &'static [SymbolKind] =
361        &[SymbolKind::Interface, SymbolKind::Class];
362    const NAME: &'static str = "typescript";
363    const MANIFEST_FILES: &'static [&'static str] = &["package.json"];
364    const SOURCE_FILE_PATTERNS: &'static [&'static str] = &["*.ts", "*.tsx"];
365
366    fn extract(
367        &self,
368        repo: &Path,
369        git_ref: &str,
370        degradation: Option<&semver_analyzer_core::diagnostics::DegradationTracker>,
371    ) -> Result<ApiSurface<TsSymbolData>> {
372        let extractor = crate::extract::OxcExtractor::new();
373        extractor.extract_at_ref(repo, git_ref, self.build_command.as_deref(), degradation)
374    }
375
376    fn extract_keeping_worktree(
377        &self,
378        repo: &Path,
379        git_ref: &str,
380        degradation: Option<&semver_analyzer_core::diagnostics::DegradationTracker>,
381    ) -> Result<semver_analyzer_core::ExtractionWithWorktree<TsSymbolData>> {
382        use crate::worktree::{ExtractionWarning, WorktreeGuard};
383        use semver_analyzer_core::error::DiagnoseWithTip;
384
385        let guard = WorktreeGuard::new(repo, git_ref, self.build_command.as_deref()).diagnose()?;
386
387        // Record extraction warnings as degradation (same detail as extract_at_ref)
388        if let Some(tracker) = degradation {
389            for warning in guard.warnings() {
390                match warning {
391                    ExtractionWarning::PartialTscBuildFailed {
392                        succeeded, failed, ..
393                    } => {
394                        tracker.record(
395                            "TD",
396                            format!(
397                                "tsc partially succeeded ({} packages ok, {} failed) \
398                                 and project build also failed at ref {}",
399                                succeeded, failed, git_ref
400                            ),
401                            "API surface may be incomplete — some package \
402                             declarations could not be generated",
403                        );
404                    }
405                    ExtractionWarning::TscFailedBuildSucceeded { .. } => {
406                        tracker.record(
407                            "TD",
408                            format!("tsc failed at ref {}, fell back to project build", git_ref),
409                            "API surface was extracted via project build — \
410                             coverage should be complete",
411                        );
412                    }
413                }
414            }
415        }
416
417        let guard = Arc::new(guard);
418        let extractor = crate::extract::OxcExtractor::new();
419        let surface = extractor.extract_from_dir(guard.path())?;
420        Ok((
421            surface,
422            Some(guard as Arc<dyn semver_analyzer_core::traits::WorktreeAccess>),
423        ))
424    }
425
426    fn parse_changed_functions(
427        &self,
428        repo: &Path,
429        from_ref: &str,
430        to_ref: &str,
431    ) -> Result<Vec<ChangedFunction>> {
432        let parser = crate::diff_parser::TsDiffParser::new();
433        parser.parse_changed_functions(repo, from_ref, to_ref)
434    }
435
436    fn find_callers(&self, file: &Path, symbol_name: &str) -> Result<Vec<Caller>> {
437        let cg = crate::call_graph::TsCallGraphBuilder::new();
438        cg.find_callers(file, symbol_name)
439    }
440
441    fn find_references(&self, file: &Path, symbol_name: &str) -> Result<Vec<Reference>> {
442        let cg = crate::call_graph::TsCallGraphBuilder::new();
443        cg.find_references(file, symbol_name)
444    }
445
446    fn find_tests(&self, repo: &Path, source_file: &Path) -> Result<Vec<TestFile>> {
447        let ta = crate::test_analyzer::TsTestAnalyzer::new();
448        ta.find_tests(repo, source_file)
449    }
450
451    fn diff_test_assertions(
452        &self,
453        repo: &Path,
454        test_file: &TestFile,
455        from_ref: &str,
456        to_ref: &str,
457    ) -> Result<TestDiff> {
458        let ta = crate::test_analyzer::TsTestAnalyzer::new();
459        ta.diff_test_assertions(repo, test_file, from_ref, to_ref)
460    }
461
462    fn build_report(
463        &self,
464        results: &AnalysisResult<Self>,
465        repo: &Path,
466        from_ref: &str,
467        to_ref: &str,
468    ) -> AnalysisReport<Self> {
469        crate::report::build_report(results, repo, from_ref, to_ref)
470    }
471
472    fn behavioral_change_kind(&self, evidence_type: &EvidenceType) -> BehavioralChangeKind {
473        match evidence_type {
474            EvidenceType::TestDelta => BehavioralChangeKind::Function,
475            _ => BehavioralChangeKind::Class, // component-level for React
476        }
477    }
478
479    fn extract_referenced_symbols(&self, description: &str) -> Vec<String> {
480        let mut refs = Vec::new();
481        let mut seen = HashSet::new();
482
483        // Pattern 1: JSX-style <ComponentName> or <ComponentName ...>
484        let mut remaining = description;
485        while let Some(start) = remaining.find('<') {
486            let after_lt = &remaining[start + 1..];
487            let end = after_lt.find(['>', ' ', '/']).unwrap_or(after_lt.len());
488            let name = &after_lt[..end];
489            if !name.is_empty()
490                && name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
491                && name.chars().all(|c| c.is_ascii_alphanumeric())
492                && name.chars().any(|c| c.is_ascii_lowercase())
493                && seen.insert(name.to_string())
494            {
495                refs.push(name.to_string());
496            }
497            remaining = &remaining[start + 1..];
498        }
499
500        // Pattern 2: backtick-quoted PascalCase identifiers like `Modal`
501        let mut remaining = description;
502        while let Some(start) = remaining.find('`') {
503            let after_tick = &remaining[start + 1..];
504            if let Some(end) = after_tick.find('`') {
505                let name = &after_tick[..end];
506                if !name.is_empty()
507                    && name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
508                    && name.chars().all(|c| c.is_ascii_alphanumeric())
509                    && name.chars().any(|c| c.is_ascii_lowercase())
510                    && !name.contains(' ')
511                    && seen.insert(name.to_string())
512                {
513                    refs.push(name.to_string());
514                }
515                remaining = &after_tick[end + 1..];
516            } else {
517                break;
518            }
519        }
520
521        refs
522    }
523
524    fn display_name(&self, qualified_name: &str) -> String {
525        // Split on :: to get file prefix and symbol parts
526        let parts: Vec<&str> = qualified_name.split("::").collect();
527        match parts.len() {
528            0 | 1 => qualified_name.to_string(),
529            2 => parts[1].to_string(),
530            _ => parts[1..].join("."),
531        }
532    }
533
534    fn llm_categories(&self) -> Vec<semver_analyzer_core::LlmCategoryDefinition> {
535        use semver_analyzer_core::LlmCategoryDefinition;
536        vec![
537            LlmCategoryDefinition {
538                id: "dom_structure".into(),
539                label: "DOM/render changes".into(),
540                description: "Changed element types (e.g., `<header>` → `<div>`), \
541                    added/removed wrapper elements, altered component nesting structure, \
542                    children wrapping changes"
543                    .into(),
544            },
545            LlmCategoryDefinition {
546                id: "css_class".into(),
547                label: "CSS changes".into(),
548                description: "Class name renames (e.g., pf-v5-* → pf-v6-*), removed \
549                    CSS classes, changed class application logic, modifier classes \
550                    no longer applied"
551                    .into(),
552            },
553            LlmCategoryDefinition {
554                id: "css_variable".into(),
555                label: "CSS variable changes".into(),
556                description: "Renamed or removed CSS custom properties \
557                    (e.g., --pf-v5-* → --pf-v6-*)"
558                    .into(),
559            },
560            LlmCategoryDefinition {
561                id: "accessibility".into(),
562                label: "Accessibility changes".into(),
563                description: "Added/removed/changed ARIA attributes (aria-label, \
564                    aria-labelledby, aria-describedby, aria-hidden), changed `role` \
565                    attributes, keyboard navigation changes, focus management changes, \
566                    tab order changes (tabIndex additions/removals)"
567                    .into(),
568            },
569            LlmCategoryDefinition {
570                id: "default_value".into(),
571                label: "Default value changes".into(),
572                description: "Changed default prop values that alter behavior".into(),
573            },
574            LlmCategoryDefinition {
575                id: "logic_change".into(),
576                label: "Logic changes".into(),
577                description: "Changed conditional logic, removed code paths, altered \
578                    return values for same inputs, changed event handler types, removed \
579                    or changed event emissions"
580                    .into(),
581            },
582            LlmCategoryDefinition {
583                id: "data_attribute".into(),
584                label: "Data attribute changes".into(),
585                description: "Changed data-ouia-component-type, data-testid, or other \
586                    data-* attributes"
587                    .into(),
588            },
589            LlmCategoryDefinition {
590                id: "render_output".into(),
591                label: "Other render output".into(),
592                description: "Any other change to what is visually rendered that \
593                    doesn't fit above"
594                    .into(),
595            },
596        ]
597    }
598
599    fn diff_manifest_content(old: &str, new: &str) -> Vec<ManifestChange<Self>> {
600        let old_json: serde_json::Value = match serde_json::from_str(old) {
601            Ok(v) => v,
602            Err(_) => return Vec::new(),
603        };
604        let new_json: serde_json::Value = match serde_json::from_str(new) {
605            Ok(v) => v,
606            Err(_) => return Vec::new(),
607        };
608        crate::manifest::diff_manifests(&old_json, &new_json)
609    }
610
611    fn discover_package_manifests(repo: &Path, git_ref: &str) -> Vec<(String, String)> {
612        let mut results = Vec::new();
613
614        // Use `git ls-tree` to discover workspace packages under packages/
615        let output = match std::process::Command::new("git")
616            .args(["ls-tree", "--name-only", git_ref, "packages/"])
617            .current_dir(repo)
618            .output()
619        {
620            Ok(o) if o.status.success() => o,
621            _ => return results,
622        };
623
624        let listing = String::from_utf8_lossy(&output.stdout);
625        for line in listing.lines() {
626            let dir_name = line.trim_start_matches("packages/");
627            if dir_name.is_empty() {
628                continue;
629            }
630
631            let pkg_json_path = format!("{}/package.json", line);
632
633            // Read the package.json at this ref to get the npm package name
634            if let Some(content) =
635                semver_analyzer_core::git::read_git_file(repo, git_ref, &pkg_json_path)
636            {
637                let name = serde_json::from_str::<serde_json::Value>(&content)
638                    .ok()
639                    .and_then(|v| v.get("name")?.as_str().map(|s| s.to_string()))
640                    .unwrap_or_else(|| dir_name.to_string());
641
642                results.push((pkg_json_path, name));
643            }
644        }
645
646        tracing::debug!(
647            count = results.len(),
648            packages = ?results.iter().map(|(_, n)| n.as_str()).collect::<Vec<_>>(),
649            "Discovered workspace package manifests"
650        );
651
652        results
653    }
654
655    fn should_exclude_from_analysis(path: &Path) -> bool {
656        let basename = path
657            .file_name()
658            .map(|f| f.to_string_lossy().to_string())
659            .unwrap_or_default();
660        let path_str = path.to_string_lossy();
661
662        // Barrel/index files
663        basename == "index.ts" || basename == "index.tsx" || basename == "index.js"
664        // Declaration files
665        || basename.ends_with(".d.ts")
666        // Test files
667        || basename.contains(".test.") || basename.contains(".spec.")
668        // Test directories and build output
669        || path_str.contains("__tests__")
670        || path_str.contains("/dist/")
671        || path_str.starts_with("dist/")
672    }
673
674    fn run_extended_analysis(
675        &self,
676        params: &ExtendedAnalysisParams,
677    ) -> Result<TsAnalysisExtensions> {
678        let css_profiles = params.dep_dir.as_deref().and_then(|dir| {
679            crate::css_profile::extract_css_profiles_from_dir(dir)
680                .map_err(|e| {
681                    tracing::warn!(%e, "failed to extract CSS profiles from dependency");
682                    e
683                })
684                .ok()
685        });
686
687        let mut sd_result = crate::sd_pipeline::run_sd(
688            &params.repo,
689            &params.from_ref,
690            &params.to_ref,
691            css_profiles.as_ref(),
692            params.from_worktree_path.as_deref(),
693            params.to_worktree_path.as_deref(),
694        )?;
695
696        // Wire orchestrator-computed data into the SD result
697        sd_result.removed_css_blocks = params.removed_dep_components.clone();
698        sd_result.dead_css_classes_after_swap = params.dead_css_classes_after_swap.clone();
699        sd_result.dep_repo_packages = params.dep_repo_packages.clone();
700
701        Ok(TsAnalysisExtensions {
702            sd_result: Some(sd_result),
703            hierarchy_deltas: Vec::new(),
704            new_hierarchies: std::collections::HashMap::new(),
705        })
706    }
707
708    fn finalize_extensions(
709        &self,
710        extensions: &mut Self::AnalysisExtensions,
711        structural_changes: Arc<Vec<StructuralChange>>,
712        repo: &std::path::Path,
713        from_ref: &str,
714        to_ref: &str,
715    ) -> Arc<Vec<StructuralChange>> {
716        let sd = match extensions.sd_result.as_mut() {
717            Some(sd) => sd,
718            None => return structural_changes,
719        };
720
721        // Step 1: Deprecated replacement detection via rendering swaps (primary)
722        let mut deprecated_replacements =
723            crate::deprecated_replacements::detect_deprecated_replacements(&structural_changes, sd);
724
725        // Step 2: Commit co-change fallback (for components not detected by rendering swap)
726        let already_detected: std::collections::HashSet<&str> = deprecated_replacements
727            .iter()
728            .map(|r| r.old_component.as_str())
729            .collect();
730        let commit_replacements =
731            crate::deprecated_replacements::detect_deprecated_replacements_from_commits(
732                repo,
733                from_ref,
734                to_ref,
735                &structural_changes,
736                &already_detected,
737            );
738        deprecated_replacements.extend(commit_replacements);
739
740        // Log all detected replacements
741        if !deprecated_replacements.is_empty() {
742            for dr in &deprecated_replacements {
743                tracing::info!(
744                    old = %dr.old_component,
745                    new = %dr.new_component,
746                    source = ?dr.evidence_source,
747                    evidence = ?dr.evidence_hosts,
748                    "Deprecated replacement detected"
749                );
750            }
751            sd.deprecated_replacements = deprecated_replacements;
752        }
753
754        // Transform structural changes
755        crate::deprecated_replacements::apply_deprecated_replacements(
756            structural_changes,
757            &sd.deprecated_replacements,
758        )
759    }
760
761    fn extensions_log_summary(&self, extensions: &Self::AnalysisExtensions) -> Vec<String> {
762        let mut lines = Vec::new();
763        if let Some(ref sd) = extensions.sd_result {
764            lines.push(format!(
765                "[SD] {} source-level changes, {} composition trees, {} conformance checks",
766                sd.source_level_changes.len(),
767                sd.composition_trees.len(),
768                sd.conformance_checks.len(),
769            ));
770            if !sd.composition_changes.is_empty() {
771                lines.push(format!(
772                    "[SD] {} composition changes detected",
773                    sd.composition_changes.len(),
774                ));
775            }
776            if !sd.deprecated_replacements.is_empty() {
777                lines.push(format!(
778                    "[SD] {} deprecated replacements detected via rendering swaps",
779                    sd.deprecated_replacements.len(),
780                ));
781            }
782        }
783        lines
784    }
785}
786
787// ── HierarchySemantics (React component hierarchy) ─────────────────────
788
789impl HierarchySemantics<TsSymbolData> for TypeScript {
790    fn family_source_paths(&self, repo: &Path, git_ref: &str, family_name: &str) -> Vec<String> {
791        let output = std::process::Command::new("git")
792            .args(["ls-tree", "-r", "--name-only", git_ref])
793            .current_dir(repo)
794            .output();
795
796        let all_files = match output {
797            Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
798            _ => return Vec::new(),
799        };
800
801        let mut source_files = Vec::new();
802        for line in all_files.lines() {
803            if !line.ends_with(".tsx") && !line.ends_with(".ts") {
804                continue;
805            }
806            if line.contains("__tests__")
807                || line.contains("__mocks__")
808                || line.contains("__snapshots__")
809                || line.contains("/stories/")
810            {
811                continue;
812            }
813            // Check if this file is in the family directory.
814            // Exclude `next/` and `deprecated/` staging directories — these
815            // contain preview or compat copies of components that would confuse
816            // hierarchy inference by showing two versions of the same component.
817            if line.contains("/next/components/") || line.contains("/deprecated/components/") {
818                continue;
819            }
820            let parts: Vec<&str> = line.rsplitn(2, '/').collect();
821            if parts.len() < 2 {
822                continue;
823            }
824            let dir = parts[1];
825            let is_family_dir = dir.ends_with(&format!("/{}", family_name))
826                || dir.ends_with(&format!("/components/{}", family_name));
827            if is_family_dir {
828                source_files.push(line.to_string());
829            }
830        }
831
832        source_files
833    }
834
835    fn family_name_from_symbols(&self, symbols: &[&Symbol<TsSymbolData>]) -> Option<String> {
836        // Extract the component directory name from the first symbol's file path
837        for sym in symbols {
838            let path = sym.file.to_string_lossy();
839            if let Some(name) = extract_family_from_path(&path) {
840                return Some(name);
841            }
842        }
843        None
844    }
845
846    fn is_hierarchy_candidate(&self, sym: &Symbol<TsSymbolData>) -> bool {
847        // React components are PascalCase functions, classes, variables, or constants
848        matches!(
849            sym.kind,
850            SymbolKind::Variable | SymbolKind::Class | SymbolKind::Function | SymbolKind::Constant
851        ) && sym
852            .name
853            .chars()
854            .next()
855            .map(|c| c.is_uppercase())
856            .unwrap_or(false)
857    }
858
859    fn cross_family_relationships(
860        &self,
861        repo: &Path,
862        git_ref: &str,
863    ) -> Vec<(String, String, String)> {
864        use regex::Regex;
865
866        let output = match std::process::Command::new("git")
867            .args(["ls-tree", "-r", "--name-only", git_ref])
868            .current_dir(repo)
869            .output()
870        {
871            Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
872            _ => return Vec::new(),
873        };
874
875        let re =
876            Regex::new(r"import\s+\{[^}]*?(\w*Context\w*)[^}]*\}\s+from\s+'\.\./([\w]+)/").unwrap();
877
878        let mut relationships = Vec::new();
879        let mut seen = HashSet::new();
880
881        for file_path in output.lines() {
882            if (!file_path.ends_with(".tsx") && !file_path.ends_with(".ts"))
883                || file_path.contains("__tests__")
884                || file_path.contains("/examples/")
885                || file_path.contains("/deprecated/")
886                || file_path.contains("/stories/")
887            {
888                continue;
889            }
890            if !file_path.contains("/components/") {
891                continue;
892            }
893            let consumer_family = match extract_family_from_path(file_path) {
894                Some(f) => f,
895                None => continue,
896            };
897
898            let content = match read_git_file(repo, git_ref, file_path) {
899                Some(c) => c,
900                None => continue,
901            };
902
903            for cap in re.captures_iter(&content) {
904                let context_name = cap[1].to_string();
905                let provider_family = cap[2].to_string();
906                if provider_family == consumer_family {
907                    continue;
908                }
909                let key = (
910                    consumer_family.clone(),
911                    provider_family.clone(),
912                    context_name.clone(),
913                );
914                if seen.insert(key) {
915                    relationships.push((
916                        consumer_family.clone(),
917                        provider_family.clone(),
918                        context_name,
919                    ));
920                }
921            }
922        }
923
924        relationships
925    }
926
927    fn compute_deterministic_hierarchy(
928        &self,
929        new_surface: &ApiSurface<TsSymbolData>,
930        structural_changes: &[StructuralChange],
931    ) -> std::collections::HashMap<String, std::collections::HashMap<String, Vec<ExpectedChild>>>
932    {
933        use semver_analyzer_core::ChangeSubject;
934        use std::collections::{BTreeMap, HashMap};
935
936        // ── Index: group hierarchy candidates by family ──────────────
937        let mut families: HashMap<String, Vec<&Symbol<TsSymbolData>>> = HashMap::new();
938        for sym in &new_surface.symbols {
939            if !self.is_hierarchy_candidate(sym) {
940                continue;
941            }
942            if let Some(family) = self.family_name_from_symbols(&[sym]) {
943                families.entry(family).or_default().push(sym);
944            }
945        }
946
947        // ── Index: interface extends map ─────────────────────────────
948        //
949        // Maps interface name → what it extends.
950        // e.g., "DropdownProps" → "MenuProps"
951        let mut iface_extends: HashMap<&str, &str> = HashMap::new();
952        for sym in &new_surface.symbols {
953            if sym.kind == SymbolKind::Interface {
954                if let Some(ext) = &sym.extends {
955                    iface_extends.insert(&sym.name, ext.as_str());
956                }
957            }
958        }
959
960        // ── Index: component → props interface name ──────────────────
961        //
962        // Convention: component "Dropdown" → props interface "DropdownProps".
963        // We verify the interface actually exists in the surface.
964        let iface_names: HashSet<&str> = new_surface
965            .symbols
966            .iter()
967            .filter(|s| s.kind == SymbolKind::Interface)
968            .map(|s| s.name.as_str())
969            .collect();
970
971        // ── Index: props interface → component name ──────────────────
972        //
973        // Reverse mapping: "MenuProps" → "Menu", "MenuListProps" → "MenuList"
974        // Used for cross-family extends resolution.
975        let mut props_to_component: HashMap<String, &str> = HashMap::new();
976        for sym in &new_surface.symbols {
977            if !self.is_hierarchy_candidate(sym) {
978                continue;
979            }
980            let props_name = format!("{}Props", sym.name);
981            if iface_names.contains(props_name.as_str()) {
982                props_to_component.insert(props_name, &sym.name);
983            }
984        }
985
986        // ── Signal 1: Prop absorption ────────────────────────────────
987        //
988        // For each parent interface with removed members, find new family
989        // members whose props interface has matching member names.
990        let mut removed_props_by_parent: HashMap<String, HashSet<String>> = HashMap::new();
991        for change in structural_changes {
992            if let StructuralChangeType::Removed(ChangeSubject::Member { name, .. }) =
993                &change.change_type
994            {
995                let parent = if let Some((p, _)) = change.symbol.rsplit_once('.') {
996                    p.strip_suffix("Props").unwrap_or(p).to_string()
997                } else {
998                    change
999                        .symbol
1000                        .strip_suffix("Props")
1001                        .unwrap_or(&change.symbol)
1002                        .to_string()
1003                };
1004                removed_props_by_parent
1005                    .entry(parent)
1006                    .or_default()
1007                    .insert(name.clone());
1008            }
1009        }
1010
1011        // For each family, check which new members absorbed removed props.
1012        let mut absorption_children: HashMap<String, BTreeMap<String, Vec<String>>> =
1013            HashMap::new();
1014
1015        for members in families.values() {
1016            for parent in members.iter() {
1017                let removed = match removed_props_by_parent.get(&parent.name) {
1018                    Some(r) if !r.is_empty() => r,
1019                    _ => continue,
1020                };
1021
1022                for candidate in members.iter() {
1023                    if candidate.name == parent.name {
1024                        continue;
1025                    }
1026
1027                    let candidate_props: HashSet<&str> =
1028                        candidate.members.iter().map(|m| m.name.as_str()).collect();
1029
1030                    let props_iface_name = format!("{}Props", candidate.name);
1031                    let iface_props: HashSet<&str> = new_surface
1032                        .symbols
1033                        .iter()
1034                        .find(|s| s.name == props_iface_name && s.kind == SymbolKind::Interface)
1035                        .map(|s| s.members.iter().map(|m| m.name.as_str()).collect())
1036                        .unwrap_or_default();
1037
1038                    let all_candidate_props: HashSet<&str> =
1039                        candidate_props.union(&iface_props).copied().collect();
1040
1041                    let absorbed: Vec<String> = removed
1042                        .iter()
1043                        .filter(|prop| all_candidate_props.contains(prop.as_str()))
1044                        .cloned()
1045                        .collect();
1046
1047                    if !absorbed.is_empty() {
1048                        absorption_children
1049                            .entry(parent.name.clone())
1050                            .or_default()
1051                            .insert(candidate.name.clone(), absorbed);
1052                    }
1053                }
1054            }
1055        }
1056
1057        // ── Signal 2: Cross-family extends mapping ───────────────────
1058        //
1059        // If Dropdown renders Menu (from Menu family), and DropdownList's
1060        // props extend MenuListProps → DropdownList maps to MenuList.
1061        let mut extends_map: HashMap<&str, &str> = HashMap::new();
1062        for members in families.values() {
1063            for sym in members {
1064                let props_name = format!("{}Props", sym.name);
1065                if let Some(ext_iface) = iface_extends.get(props_name.as_str()) {
1066                    // Strip Omit<...> wrapper if present
1067                    let ext_clean = ext_iface
1068                        .strip_prefix("Omit<")
1069                        .and_then(|s| s.split(',').next())
1070                        .unwrap_or(ext_iface);
1071                    if let Some(ext_component) = props_to_component.get(ext_clean) {
1072                        // Only cross-family: the extended component should NOT be
1073                        // in the same family.
1074                        let ext_family = self.family_name_from_symbols(&[new_surface
1075                            .symbols
1076                            .iter()
1077                            .find(|s| s.name.as_str() == *ext_component)
1078                            .unwrap_or(sym)]);
1079                        let own_family = self.family_name_from_symbols(&[sym]);
1080                        if ext_family != own_family {
1081                            extends_map.insert(&sym.name, ext_component);
1082                        }
1083                    }
1084                }
1085            }
1086        }
1087
1088        // ── Combine signals into hierarchy ───────────────────────────
1089        let mut result: HashMap<String, HashMap<String, Vec<ExpectedChild>>> = HashMap::new();
1090
1091        for (family_name, members) in &families {
1092            let member_names: HashSet<&str> = members.iter().map(|s| s.name.as_str()).collect();
1093            let mut family_hierarchy: HashMap<String, Vec<ExpectedChild>> = HashMap::new();
1094
1095            // Signal 3: internal rendering (from TsSymbolData.rendered_components)
1096            let mut renders_family: HashMap<&str, HashSet<&str>> = HashMap::new();
1097            for sym in members {
1098                let family_renders: HashSet<&str> = sym
1099                    .language_data
1100                    .rendered_components
1101                    .iter()
1102                    .filter(|r| {
1103                        member_names.contains(r.as_str()) && r.as_str() != sym.name.as_str()
1104                    })
1105                    .map(|r| r.as_str())
1106                    .collect();
1107                if !family_renders.is_empty() {
1108                    renders_family.insert(&sym.name, family_renders);
1109                }
1110            }
1111
1112            for parent in members.iter() {
1113                let mut children: BTreeMap<&str, ExpectedChild> = BTreeMap::new();
1114
1115                // ── Signal 1: absorption ─────────────────────────────
1116                if let Some(absorbed) = absorption_children.get(&parent.name) {
1117                    for child_name in absorbed.keys() {
1118                        if !member_names.contains(child_name.as_str()) {
1119                            continue;
1120                        }
1121                        let parent_renders = renders_family.get(parent.name.as_str());
1122                        let is_rendered = parent_renders
1123                            .map(|r| r.contains(child_name.as_str()))
1124                            .unwrap_or(false);
1125
1126                        let child = if is_rendered {
1127                            ExpectedChild {
1128                                name: child_name.clone(),
1129                                required: false,
1130                                mechanism: "prop".to_string(),
1131                                prop_name: None,
1132                            }
1133                        } else {
1134                            ExpectedChild::new(child_name, false)
1135                        };
1136                        children.insert(child_name.as_str(), child);
1137                    }
1138                }
1139
1140                // ── Signal 2: cross-family extends mapping ───────────
1141                if let Some(ext_parent) = extends_map.get(parent.name.as_str()) {
1142                    let renders_ext_parent = parent
1143                        .language_data
1144                        .rendered_components
1145                        .iter()
1146                        .any(|r| r.as_str() == *ext_parent);
1147
1148                    let ext_parent_sym = new_surface
1149                        .symbols
1150                        .iter()
1151                        .find(|s| s.name.as_str() == *ext_parent);
1152                    let ext_parent_is_container = ext_parent_sym
1153                        .map(|ep| {
1154                            let ep_family = self.family_name_from_symbols(&[ep]);
1155                            ep.language_data.rendered_components.iter().any(|rc| {
1156                                new_surface
1157                                    .symbols
1158                                    .iter()
1159                                    .filter(|s| self.is_hierarchy_candidate(s))
1160                                    .any(|s| {
1161                                        s.name.as_str() == rc.as_str()
1162                                            && self.family_name_from_symbols(&[s]) == ep_family
1163                                    })
1164                            })
1165                        })
1166                        .unwrap_or(false);
1167
1168                    if renders_ext_parent && ext_parent_is_container {
1169                        if let Some(ext_sym) = ext_parent_sym {
1170                            for candidate in members.iter() {
1171                                if candidate.name == parent.name {
1172                                    continue;
1173                                }
1174                                if children.contains_key(candidate.name.as_str()) {
1175                                    continue;
1176                                }
1177
1178                                if let Some(ext_child) = extends_map.get(candidate.name.as_str()) {
1179                                    let ext_renders_child = ext_sym
1180                                        .language_data
1181                                        .rendered_components
1182                                        .contains(&ext_child.to_string());
1183
1184                                    if !ext_renders_child {
1185                                        let ext_child_sym = new_surface
1186                                            .symbols
1187                                            .iter()
1188                                            .find(|s| s.name.as_str() == *ext_child);
1189                                        let ext_child_is_container = ext_child_sym
1190                                            .map(|ec| {
1191                                                let ec_family =
1192                                                    self.family_name_from_symbols(&[ec]);
1193                                                ec.language_data.rendered_components.iter().any(
1194                                                    |rc| {
1195                                                        new_surface
1196                                                            .symbols
1197                                                            .iter()
1198                                                            .filter(|s| {
1199                                                                self.is_hierarchy_candidate(s)
1200                                                            })
1201                                                            .any(|s| {
1202                                                                s.name.as_str() == rc.as_str()
1203                                                                    && self
1204                                                                        .family_name_from_symbols(
1205                                                                            &[s],
1206                                                                        )
1207                                                                        == ec_family
1208                                                            })
1209                                                    },
1210                                                )
1211                                            })
1212                                            .unwrap_or(false);
1213
1214                                        if !ext_child_is_container {
1215                                            children.insert(
1216                                                &candidate.name,
1217                                                ExpectedChild::new(&candidate.name, false),
1218                                            );
1219                                        }
1220                                    }
1221                                }
1222                            }
1223                        }
1224                    }
1225                }
1226
1227                if !children.is_empty() {
1228                    family_hierarchy.insert(parent.name.clone(), children.into_values().collect());
1229                }
1230            }
1231
1232            if !family_hierarchy.is_empty() {
1233                result.insert(family_name.clone(), family_hierarchy);
1234            }
1235        }
1236
1237        result
1238    }
1239
1240    fn related_family_content(
1241        &self,
1242        repo: &Path,
1243        git_ref: &str,
1244        family_name: &str,
1245        relationship_names: &[String],
1246    ) -> Option<String> {
1247        let output = std::process::Command::new("git")
1248            .args(["ls-tree", "-r", "--name-only", git_ref])
1249            .current_dir(repo)
1250            .output()
1251            .ok()?;
1252
1253        if !output.status.success() {
1254            return None;
1255        }
1256
1257        let all_files = String::from_utf8_lossy(&output.stdout);
1258        let mut content = String::new();
1259
1260        for line in all_files.lines() {
1261            if !line.ends_with(".tsx") && !line.ends_with(".ts") {
1262                continue;
1263            }
1264            if line.contains("__tests__")
1265                || line.contains("/examples/")
1266                || line.contains("/deprecated/")
1267                || line.contains("/stories/")
1268                || line.contains("index.ts")
1269            {
1270                continue;
1271            }
1272            let file_family = match extract_family_from_path(line) {
1273                Some(f) => f,
1274                None => continue,
1275            };
1276            if file_family != family_name {
1277                continue;
1278            }
1279            let file_content = match read_git_file(repo, git_ref, line) {
1280                Some(c) => c,
1281                None => continue,
1282            };
1283            let uses_context = relationship_names
1284                .iter()
1285                .any(|ctx| file_content.contains(ctx));
1286            if !uses_context {
1287                continue;
1288            }
1289            content.push_str(&format!(
1290                "\n--- Related: {} (uses {}) ---\n",
1291                line,
1292                relationship_names.join(", "),
1293            ));
1294            content.push_str(&file_content);
1295            content.push('\n');
1296        }
1297
1298        if content.is_empty() {
1299            None
1300        } else {
1301            Some(content)
1302        }
1303    }
1304}
1305
1306// ── RenameSemantics (PatternFly-specific rename patterns) ──────────────
1307
1308impl RenameSemantics for TypeScript {
1309    fn sample_removed_constants<'a>(
1310        &self,
1311        removed: &[&'a str],
1312        _added: &[&'a str],
1313    ) -> Vec<&'a str> {
1314        let directional_suffixes = [
1315            "Top",
1316            "Bottom",
1317            "Left",
1318            "Right",
1319            "Width",
1320            "Height",
1321            "MaxWidth",
1322            "MaxHeight",
1323            "MinWidth",
1324            "MinHeight",
1325        ];
1326        let mut sample: Vec<&'a str> = removed
1327            .iter()
1328            .filter(|s| directional_suffixes.iter().any(|d| s.ends_with(d)))
1329            .take(20)
1330            .copied()
1331            .collect();
1332        for s in removed.iter() {
1333            if sample.len() >= 30 {
1334                break;
1335            }
1336            if !sample.contains(s) {
1337                sample.push(s);
1338            }
1339        }
1340        sample
1341    }
1342
1343    fn sample_added_constants<'a>(&self, _removed: &[&'a str], added: &[&'a str]) -> Vec<&'a str> {
1344        let logical_suffixes = [
1345            "BlockStart",
1346            "BlockEnd",
1347            "InlineStart",
1348            "InlineEnd",
1349            "InlineSize",
1350            "BlockSize",
1351        ];
1352        let mut sample: Vec<&'a str> = added
1353            .iter()
1354            .filter(|s| logical_suffixes.iter().any(|d| s.contains(d)))
1355            .take(20)
1356            .copied()
1357            .collect();
1358        for s in added.iter() {
1359            if sample.len() >= 30 {
1360                break;
1361            }
1362            if !sample.contains(s) {
1363                sample.push(s);
1364            }
1365        }
1366        sample
1367    }
1368}
1369
1370// ── BodyAnalysisSemantics (JSX diff + CSS scan) ────────────────────────
1371
1372impl BodyAnalysisSemantics for TypeScript {
1373    fn analyze_changed_body(
1374        &self,
1375        old_body: &str,
1376        new_body: &str,
1377        func_name: &str,
1378        file_path: &str,
1379    ) -> Vec<BodyAnalysisResult> {
1380        let mut results = Vec::new();
1381
1382        let file = Path::new(file_path);
1383
1384        // JSX diff analysis
1385        if crate::jsx_diff::body_contains_jsx(old_body)
1386            && crate::jsx_diff::body_contains_jsx(new_body)
1387        {
1388            let jsx_changes = crate::jsx_diff::diff_jsx_bodies(old_body, new_body, func_name, file);
1389            for jsx_change in jsx_changes {
1390                results.push(BodyAnalysisResult {
1391                    description: jsx_change.description,
1392                    category_label: Some(ts_category_label(&jsx_change.category).to_string()),
1393                    confidence: 0.90,
1394                });
1395            }
1396        }
1397
1398        // CSS variable/class scanning
1399        if crate::css_scan::body_contains_css_refs(old_body)
1400            || crate::css_scan::body_contains_css_refs(new_body)
1401        {
1402            let css_changes =
1403                crate::css_scan::diff_css_references(old_body, new_body, func_name, file);
1404            for css_change in css_changes {
1405                results.push(BodyAnalysisResult {
1406                    description: css_change.description,
1407                    category_label: Some(ts_category_label(&css_change.category).to_string()),
1408                    confidence: 0.90,
1409                });
1410            }
1411        }
1412
1413        results
1414    }
1415}
1416
1417/// Convert a TsCategory to a snake_case string label.
1418pub fn ts_category_label(cat: &TsCategory) -> &'static str {
1419    match cat {
1420        TsCategory::DomStructure => "dom_structure",
1421        TsCategory::CssClass => "css_class",
1422        TsCategory::CssVariable => "css_variable",
1423        TsCategory::Accessibility => "accessibility",
1424        TsCategory::DefaultValue => "default_value",
1425        TsCategory::LogicChange => "logic_change",
1426        TsCategory::DataAttribute => "data_attribute",
1427        TsCategory::RenderOutput => "render_output",
1428    }
1429}
1430
1431/// Extract the component family directory name from a file path.
1432/// e.g., "packages/react-core/src/components/Masthead/Masthead.tsx" → "Masthead"
1433fn extract_family_from_path(path: &str) -> Option<String> {
1434    let parts: Vec<&str> = path.split('/').collect();
1435    for (i, part) in parts.iter().enumerate() {
1436        if *part == "components" && i + 1 < parts.len() && i + 2 < parts.len() {
1437            return Some(parts[i + 1].to_string());
1438        }
1439    }
1440    None
1441}
1442
1443use crate::git_utils::read_git_file;
1444
1445// ── Extracted helper functions ──────────────────────────────────────────
1446
1447/// Extract the component directory from a file path, stripping /deprecated/
1448/// and /next/ segments for canonical matching.
1449///
1450/// `packages/react-core/dist/esm/deprecated/components/Select/Select.d.ts`
1451/// → `packages/react-core/dist/esm/components/Select`
1452///
1453/// `packages/react-core/dist/esm/components/EmptyState/EmptyStateHeader.d.ts`
1454/// → `packages/react-core/dist/esm/components/EmptyState`
1455pub(crate) fn canonical_component_dir(file_path: &str) -> String {
1456    let canonical = file_path
1457        .replace("/deprecated/", "/")
1458        .replace("/next/", "/");
1459    let canonical = if canonical.starts_with("deprecated/") {
1460        canonical.strip_prefix("deprecated/").unwrap().to_string()
1461    } else {
1462        canonical
1463    };
1464    let canonical = if canonical.starts_with("next/") {
1465        canonical.strip_prefix("next/").unwrap().to_string()
1466    } else {
1467        canonical
1468    };
1469
1470    match canonical.rsplit_once('/') {
1471        Some((dir, _)) => dir.to_string(),
1472        None => canonical,
1473    }
1474}
1475
1476/// Strip a "Props" suffix from a symbol name for comparison.
1477///
1478/// `EmptyStateHeaderProps` → `EmptyStateHeader`
1479/// `SelectProps` → `Select`
1480/// `Modal` → `Modal`
1481fn strip_props_suffix(name: &str) -> &str {
1482    name.strip_suffix("Props").unwrap_or(name)
1483}
1484
1485/// Parse a TypeScript string literal union type into its individual members.
1486///
1487/// `'primary' | 'secondary' | 'tertiary'` → `{"primary", "secondary", "tertiary"}`
1488///
1489/// Also handles mixed unions like `'primary' | ButtonVariant | undefined` by
1490/// extracting only the string literal members (quoted with single or double quotes).
1491fn parse_ts_union_literals(type_str: &str) -> Option<BTreeSet<String>> {
1492    if !type_str.contains('\'') && !type_str.contains('"') {
1493        return None;
1494    }
1495    if !type_str.contains('|') {
1496        return None;
1497    }
1498
1499    let mut literals = BTreeSet::new();
1500    for part in type_str.split('|') {
1501        let trimmed = part.trim();
1502        if (trimmed.starts_with('\'') && trimmed.ends_with('\''))
1503            || (trimmed.starts_with('"') && trimmed.ends_with('"'))
1504        {
1505            let value = &trimmed[1..trimmed.len() - 1];
1506            if !value.is_empty() {
1507                literals.insert(value.to_string());
1508            }
1509        }
1510    }
1511
1512    if literals.len() >= 2 {
1513        Some(literals)
1514    } else {
1515        None
1516    }
1517}
1518
1519/// Remove redundant `default` export changes when a named sibling from the
1520/// same file has the same change type.
1521fn dedup_default_exports(changes: &mut Vec<StructuralChange>) {
1522    let named_changes: HashSet<(String, StructuralChangeType)> = changes
1523        .iter()
1524        .filter(|c| c.symbol != "default")
1525        .filter_map(|c| {
1526            file_prefix(&c.qualified_name).map(|prefix| (prefix.to_string(), c.change_type.clone()))
1527        })
1528        .collect();
1529
1530    changes.retain(|c| {
1531        if c.symbol != "default" {
1532            return true;
1533        }
1534        if let Some(prefix) = file_prefix(&c.qualified_name) {
1535            !named_changes.contains(&(prefix.to_string(), c.change_type.clone()))
1536        } else {
1537            true
1538        }
1539    });
1540}
1541
1542/// Extract the file prefix from a qualified_name (everything before the last `.`).
1543fn file_prefix(qualified_name: &str) -> Option<&str> {
1544    qualified_name.rsplit_once('.').map(|(prefix, _)| prefix)
1545}
1546
1547// ── Tests ───────────────────────────────────────────────────────────────
1548
1549#[cfg(test)]
1550mod tests {
1551    use super::*;
1552    use semver_analyzer_core::Symbol as CoreSymbol;
1553    use semver_analyzer_core::{Parameter, Signature};
1554
1555    /// In tests, `Symbol` means `Symbol<TsSymbolData>` to match trait impls.
1556    type Symbol = CoreSymbol<TsSymbolData>;
1557
1558    fn sym(name: &str, kind: SymbolKind) -> Symbol {
1559        Symbol::new(name, name, kind, Visibility::Exported, "test.d.ts", 1)
1560    }
1561
1562    fn make_interface(name: &str, file: &str, members: &[&str]) -> Symbol {
1563        let mut s = Symbol::new(
1564            name,
1565            format!("{}.{}", file, name),
1566            SymbolKind::Interface,
1567            Visibility::Exported,
1568            file,
1569            1,
1570        );
1571        for &member_name in members {
1572            s.members.push(Symbol::new(
1573                member_name,
1574                format!("{}.{}.{}", file, name, member_name),
1575                SymbolKind::Property,
1576                Visibility::Public,
1577                file,
1578                1,
1579            ));
1580        }
1581        s
1582    }
1583
1584    // ── is_member_addition_breaking ──────────────────────────────
1585
1586    #[test]
1587    fn required_member_on_interface_is_breaking() {
1588        let ts = TypeScript::default();
1589        let container = sym("ButtonProps", SymbolKind::Interface);
1590        let member = sym("onClick", SymbolKind::Property);
1591        assert!(ts.is_member_addition_breaking(&container, &member));
1592    }
1593
1594    #[test]
1595    fn optional_member_on_interface_is_not_breaking() {
1596        let ts = TypeScript::default();
1597        let container = sym("ButtonProps", SymbolKind::Interface);
1598        let mut member = sym("onClick", SymbolKind::Property);
1599        member.signature = Some(Signature {
1600            parameters: vec![Parameter {
1601                name: "onClick".into(),
1602                type_annotation: Some("() => void".into()),
1603                optional: true,
1604                has_default: false,
1605                default_value: None,
1606                is_variadic: false,
1607            }],
1608            return_type: None,
1609            type_parameters: vec![],
1610            is_async: false,
1611        });
1612        assert!(!ts.is_member_addition_breaking(&container, &member));
1613    }
1614
1615    #[test]
1616    fn member_on_enum_is_not_breaking() {
1617        let ts = TypeScript::default();
1618        let container = sym("Color", SymbolKind::Enum);
1619        let member = sym("Green", SymbolKind::EnumMember);
1620        assert!(!ts.is_member_addition_breaking(&container, &member));
1621    }
1622
1623    #[test]
1624    fn member_on_class_is_not_breaking() {
1625        let ts = TypeScript::default();
1626        let container = sym("UserService", SymbolKind::Class);
1627        let member = sym("getUser", SymbolKind::Method);
1628        assert!(!ts.is_member_addition_breaking(&container, &member));
1629    }
1630
1631    // ── same_family ─────────────────────────────────────────────
1632
1633    #[test]
1634    fn same_directory_is_same_family() {
1635        let ts = TypeScript::default();
1636        let a = make_interface("Modal", "components/Modal/Modal.d.ts", &[]);
1637        let b = make_interface("ModalHeader", "components/Modal/ModalHeader.d.ts", &[]);
1638        assert!(ts.same_family(&a, &b));
1639    }
1640
1641    #[test]
1642    fn different_directory_is_not_same_family() {
1643        let ts = TypeScript::default();
1644        let a = make_interface("Modal", "components/Modal/Modal.d.ts", &[]);
1645        let b = make_interface("Button", "components/Button/Button.d.ts", &[]);
1646        assert!(!ts.same_family(&a, &b));
1647    }
1648
1649    #[test]
1650    fn deprecated_and_main_are_same_family() {
1651        let ts = TypeScript::default();
1652        let a = make_interface("Select", "deprecated/components/Select/Select.d.ts", &[]);
1653        let b = make_interface("Select", "components/Select/Select.d.ts", &[]);
1654        assert!(ts.same_family(&a, &b));
1655    }
1656
1657    // ── same_identity ───────────────────────────────────────────
1658
1659    #[test]
1660    fn button_and_button_props_are_same_identity() {
1661        let ts = TypeScript::default();
1662        let a = sym("Button", SymbolKind::Function);
1663        let b = sym("ButtonProps", SymbolKind::Interface);
1664        assert!(ts.same_identity(&a, &b));
1665    }
1666
1667    #[test]
1668    fn same_name_is_same_identity() {
1669        let ts = TypeScript::default();
1670        let a = sym("Select", SymbolKind::Interface);
1671        let b = sym("Select", SymbolKind::Interface);
1672        assert!(ts.same_identity(&a, &b));
1673    }
1674
1675    #[test]
1676    fn different_names_are_not_same_identity() {
1677        let ts = TypeScript::default();
1678        let a = sym("Button", SymbolKind::Function);
1679        let b = sym("Select", SymbolKind::Function);
1680        assert!(!ts.same_identity(&a, &b));
1681    }
1682
1683    // ── visibility_rank ─────────────────────────────────────────
1684
1685    #[test]
1686    fn ts_visibility_ranking() {
1687        let ts = TypeScript::default();
1688        assert!(ts.visibility_rank(Visibility::Private) < ts.visibility_rank(Visibility::Internal));
1689        assert_eq!(
1690            ts.visibility_rank(Visibility::Internal),
1691            ts.visibility_rank(Visibility::Protected)
1692        );
1693        assert!(ts.visibility_rank(Visibility::Protected) < ts.visibility_rank(Visibility::Public));
1694        assert!(ts.visibility_rank(Visibility::Public) < ts.visibility_rank(Visibility::Exported));
1695    }
1696
1697    // ── parse_union_values ──────────────────────────────────────
1698
1699    #[test]
1700    fn parses_string_literal_union() {
1701        let ts = TypeScript::default();
1702        let result = ts
1703            .parse_union_values("'primary' | 'secondary' | 'danger'")
1704            .unwrap();
1705        assert_eq!(result.len(), 3);
1706        assert!(result.contains("primary"));
1707        assert!(result.contains("secondary"));
1708        assert!(result.contains("danger"));
1709    }
1710
1711    #[test]
1712    fn returns_none_for_non_union() {
1713        let ts = TypeScript::default();
1714        assert!(ts.parse_union_values("string").is_none());
1715    }
1716
1717    #[test]
1718    fn returns_none_for_single_literal() {
1719        let ts = TypeScript::default();
1720        assert!(ts.parse_union_values("'primary'").is_none());
1721    }
1722
1723    #[test]
1724    fn handles_mixed_union_with_type_refs() {
1725        let ts = TypeScript::default();
1726        let result = ts
1727            .parse_union_values("'primary' | 'secondary' | ButtonVariant | undefined")
1728            .unwrap();
1729        assert_eq!(result.len(), 2);
1730        assert!(result.contains("primary"));
1731        assert!(result.contains("secondary"));
1732    }
1733
1734    // ── post_process (dedup default exports) ────────────────────
1735
1736    #[test]
1737    fn dedup_default_keeps_named_removes_default() {
1738        use semver_analyzer_core::ChangeSubject;
1739        let ts = TypeScript::default();
1740        let mut changes = vec![
1741            StructuralChange {
1742                symbol: "c_button".into(),
1743                qualified_name: "pkg/dist/c_button.c_button".into(),
1744                kind: SymbolKind::Constant,
1745                package: None,
1746                change_type: StructuralChangeType::Removed(ChangeSubject::Symbol {
1747                    kind: SymbolKind::Constant,
1748                }),
1749                before: None,
1750                after: None,
1751                description: "removed".into(),
1752                is_breaking: true,
1753                impact: None,
1754                migration_target: None,
1755            },
1756            StructuralChange {
1757                symbol: "default".into(),
1758                qualified_name: "pkg/dist/c_button.default".into(),
1759                kind: SymbolKind::Constant,
1760                package: None,
1761                change_type: StructuralChangeType::Removed(ChangeSubject::Symbol {
1762                    kind: SymbolKind::Constant,
1763                }),
1764                before: None,
1765                after: None,
1766                description: "removed".into(),
1767                is_breaking: true,
1768                impact: None,
1769                migration_target: None,
1770            },
1771        ];
1772        ts.post_process(&mut changes);
1773        assert_eq!(changes.len(), 1);
1774        assert_eq!(changes[0].symbol, "c_button");
1775    }
1776
1777    // ── canonical_component_dir ─────────────────────────────────
1778
1779    #[test]
1780    fn strips_deprecated_segment() {
1781        assert_eq!(
1782            canonical_component_dir(
1783                "packages/react-core/dist/esm/deprecated/components/Select/Select.d.ts"
1784            ),
1785            "packages/react-core/dist/esm/components/Select"
1786        );
1787    }
1788
1789    #[test]
1790    fn strips_next_segment() {
1791        assert_eq!(
1792            canonical_component_dir(
1793                "packages/react-core/dist/esm/next/components/Modal/ModalHeader.d.ts"
1794            ),
1795            "packages/react-core/dist/esm/components/Modal"
1796        );
1797    }
1798
1799    #[test]
1800    fn normal_path_returns_directory() {
1801        assert_eq!(
1802            canonical_component_dir(
1803                "packages/react-core/dist/esm/components/EmptyState/EmptyStateHeader.d.ts"
1804            ),
1805            "packages/react-core/dist/esm/components/EmptyState"
1806        );
1807    }
1808
1809    // ── should_skip_symbol ──────────────────────────────────────
1810
1811    #[test]
1812    fn star_reexport_skipped() {
1813        let ts = TypeScript::default();
1814        let sym = Symbol::new(
1815            "*",
1816            "pkg/index.*",
1817            SymbolKind::Variable,
1818            Visibility::Exported,
1819            std::path::PathBuf::from("pkg/index.d.ts"),
1820            1,
1821        );
1822        assert!(ts.should_skip_symbol(&sym));
1823    }
1824
1825    #[test]
1826    fn normal_symbol_not_skipped() {
1827        let ts = TypeScript::default();
1828        let sym = Symbol::new(
1829            "Button",
1830            "pkg/Button.Button",
1831            SymbolKind::Variable,
1832            Visibility::Exported,
1833            std::path::PathBuf::from("pkg/Button.d.ts"),
1834            1,
1835        );
1836        assert!(!ts.should_skip_symbol(&sym));
1837    }
1838
1839    // ── extract_rename_fallback_key ─────────────────────────────
1840
1841    #[test]
1842    fn extract_css_token_value_basic() {
1843        let ts = TypeScript::default();
1844        let mut sym = Symbol::new(
1845            "global_Color_dark_100",
1846            "pkg/global_Color_dark_100",
1847            SymbolKind::Constant,
1848            Visibility::Public,
1849            std::path::PathBuf::from("pkg/global_Color_dark_100.d.ts"),
1850            1,
1851        );
1852        sym.signature = Some(semver_analyzer_core::Signature {
1853            parameters: Vec::new(),
1854            return_type: Some(
1855                "{ [\"name\"]: \"--pf-v5-global--Color--dark-100\"; [\"value\"]: \"#151515\"; [\"var\"]: \"var(--pf-v5-global--Color--dark-100)\" }"
1856                .to_string(),
1857            ),
1858            type_parameters: Vec::new(),
1859            is_async: false,
1860        });
1861        assert_eq!(
1862            ts.extract_rename_fallback_key(&sym),
1863            Some("#151515".to_string())
1864        );
1865    }
1866
1867    #[test]
1868    fn extract_css_token_value_no_signature() {
1869        let ts = TypeScript::default();
1870        let sym = Symbol::new(
1871            "global_Color_dark_100",
1872            "pkg/global_Color_dark_100",
1873            SymbolKind::Constant,
1874            Visibility::Public,
1875            std::path::PathBuf::from("pkg/global_Color_dark_100.d.ts"),
1876            1,
1877        );
1878        assert_eq!(ts.extract_rename_fallback_key(&sym), None);
1879    }
1880
1881    #[test]
1882    fn extract_css_token_value_no_value_field() {
1883        let ts = TypeScript::default();
1884        let mut sym = Symbol::new(
1885            "foo",
1886            "pkg/foo",
1887            SymbolKind::Constant,
1888            Visibility::Public,
1889            std::path::PathBuf::from("pkg/foo.d.ts"),
1890            1,
1891        );
1892        sym.signature = Some(semver_analyzer_core::Signature {
1893            parameters: Vec::new(),
1894            return_type: Some("string".to_string()),
1895            type_parameters: Vec::new(),
1896            is_async: false,
1897        });
1898        assert_eq!(ts.extract_rename_fallback_key(&sym), None);
1899    }
1900
1901    #[test]
1902    fn extract_css_token_value_calc() {
1903        let ts = TypeScript::default();
1904        let mut sym = Symbol::new(
1905            "c_button_Width",
1906            "pkg/c_button_Width",
1907            SymbolKind::Constant,
1908            Visibility::Public,
1909            std::path::PathBuf::from("pkg/c_button_Width.d.ts"),
1910            1,
1911        );
1912        sym.signature = Some(semver_analyzer_core::Signature {
1913            parameters: Vec::new(),
1914            return_type: Some(
1915                "{ [\"name\"]: \"--pf-v5-c-button--Width\"; [\"value\"]: \"calc(1.25rem * 2)\"; [\"var\"]: \"var(--pf-v5-c-button--Width)\" }"
1916                .to_string(),
1917            ),
1918            type_parameters: Vec::new(),
1919            is_async: false,
1920        });
1921        assert_eq!(
1922            ts.extract_rename_fallback_key(&sym),
1923            Some("calc(1.25rem * 2)".to_string())
1924        );
1925    }
1926
1927    // ── canonical_name_for_relocation ────────────────────────────
1928
1929    #[test]
1930    fn canonical_strips_deprecated() {
1931        let ts = TypeScript::default();
1932        assert_eq!(
1933            ts.canonical_name_for_relocation("pkg/dist/esm/deprecated/components/Chip/Chip.Chip"),
1934            "pkg/dist/esm/components/Chip/Chip.Chip"
1935        );
1936    }
1937
1938    #[test]
1939    fn canonical_strips_next() {
1940        let ts = TypeScript::default();
1941        assert_eq!(
1942            ts.canonical_name_for_relocation("pkg/dist/esm/next/components/Modal/Modal.Modal"),
1943            "pkg/dist/esm/components/Modal/Modal.Modal"
1944        );
1945    }
1946
1947    #[test]
1948    fn canonical_preserves_normal_path() {
1949        let ts = TypeScript::default();
1950        let path = "pkg/dist/esm/components/Button/Button.Button";
1951        assert_eq!(ts.canonical_name_for_relocation(path), path);
1952    }
1953
1954    // ── classify_relocation ─────────────────────────────────────
1955
1956    #[test]
1957    fn classify_moved_to_deprecated() {
1958        let ts = TypeScript::default();
1959        assert_eq!(
1960            ts.classify_relocation(
1961                "pkg/dist/esm/components/Chip/Chip.Chip",
1962                "pkg/dist/esm/deprecated/components/Chip/Chip.Chip"
1963            ),
1964            Some("moved to deprecated")
1965        );
1966    }
1967
1968    #[test]
1969    fn classify_promoted_from_deprecated() {
1970        let ts = TypeScript::default();
1971        assert_eq!(
1972            ts.classify_relocation(
1973                "pkg/dist/esm/deprecated/components/Modal/Modal.Modal",
1974                "pkg/dist/esm/components/Modal/Modal.Modal"
1975            ),
1976            Some("promoted from deprecated")
1977        );
1978    }
1979
1980    #[test]
1981    fn classify_relocated_generic() {
1982        let ts = TypeScript::default();
1983        assert_eq!(
1984            ts.classify_relocation(
1985                "pkg/dist/esm/components/Chip/Chip.Chip",
1986                "pkg/dist/esm/components/Label/Chip.Chip"
1987            ),
1988            None
1989        );
1990    }
1991
1992    #[test]
1993    fn classify_promoted_from_next() {
1994        let ts = TypeScript::default();
1995        assert_eq!(
1996            ts.classify_relocation(
1997                "pkg/dist/esm/next/components/Modal/ModalBody.ModalBody",
1998                "pkg/dist/esm/components/Modal/ModalBody.ModalBody"
1999            ),
2000            Some("promoted from next")
2001        );
2002    }
2003
2004    #[test]
2005    fn classify_moved_to_next() {
2006        let ts = TypeScript::default();
2007        assert_eq!(
2008            ts.classify_relocation(
2009                "pkg/dist/esm/components/Foo/Foo.Foo",
2010                "pkg/dist/esm/next/components/Foo/Foo.Foo"
2011            ),
2012            Some("moved to next")
2013        );
2014    }
2015
2016    // ── Deterministic hierarchy tests ───────────────────────────────
2017
2018    fn make_component(name: &str, family: &str, rendered: Vec<&str>) -> Symbol {
2019        let mut sym = Symbol::new(
2020            name,
2021            format!("src/components/{}/{}.{}", family, name, name),
2022            SymbolKind::Variable,
2023            Visibility::Exported,
2024            format!("src/components/{}/{}.d.ts", family, name),
2025            1,
2026        );
2027        sym.language_data.rendered_components = rendered.into_iter().map(String::from).collect();
2028        sym
2029    }
2030
2031    fn make_props_interface(
2032        name: &str,
2033        family: &str,
2034        extends: Option<&str>,
2035        members: &[&str],
2036    ) -> Symbol {
2037        let mut s = Symbol::new(
2038            name,
2039            format!("src/components/{}/{}.{}", family, name, name),
2040            SymbolKind::Interface,
2041            Visibility::Exported,
2042            format!("src/components/{}/{}.d.ts", family, name),
2043            1,
2044        );
2045        s.extends = extends.map(|e| e.to_string());
2046        for &member_name in members {
2047            s.members.push(Symbol::new(
2048                member_name,
2049                format!("{}.{}", name, member_name),
2050                SymbolKind::Variable,
2051                Visibility::Exported,
2052                format!("src/components/{}/{}.d.ts", family, name),
2053                1,
2054            ));
2055        }
2056        s
2057    }
2058
2059    fn removed_member(parent: &str, member: &str) -> StructuralChange {
2060        use semver_analyzer_core::ChangeSubject;
2061        StructuralChange {
2062            symbol: format!("{}.{}", parent, member),
2063            qualified_name: format!("src/components/X/{}.{}", parent, member),
2064            kind: SymbolKind::Interface,
2065            package: None,
2066            change_type: StructuralChangeType::Removed(ChangeSubject::Member {
2067                name: member.to_string(),
2068                kind: SymbolKind::Variable,
2069            }),
2070            before: None,
2071            after: None,
2072            description: format!("property `{}` was removed", member),
2073            is_breaking: true,
2074            impact: None,
2075            migration_target: None,
2076        }
2077    }
2078
2079    fn child_names(
2080        result: &std::collections::HashMap<
2081            String,
2082            std::collections::HashMap<String, Vec<ExpectedChild>>,
2083        >,
2084        family: &str,
2085        component: &str,
2086    ) -> Vec<String> {
2087        result
2088            .get(family)
2089            .and_then(|f| f.get(component))
2090            .map(|children| children.iter().map(|c| c.name.clone()).collect())
2091            .unwrap_or_default()
2092    }
2093
2094    fn child_mechanism(
2095        result: &std::collections::HashMap<
2096            String,
2097            std::collections::HashMap<String, Vec<ExpectedChild>>,
2098        >,
2099        family: &str,
2100        parent: &str,
2101        child: &str,
2102    ) -> Option<String> {
2103        result
2104            .get(family)
2105            .and_then(|f| f.get(parent))
2106            .and_then(|children| children.iter().find(|c| c.name == child))
2107            .map(|c| c.mechanism.clone())
2108    }
2109
2110    #[test]
2111    fn hierarchy_all_leaves_empty() {
2112        let ts = TypeScript::default();
2113        let surface = ApiSurface {
2114            symbols: vec![
2115                make_component("Masthead", "Masthead", vec![]),
2116                make_component("MastheadBrand", "Masthead", vec![]),
2117                make_component("MastheadContent", "Masthead", vec![]),
2118                make_component("MastheadLogo", "Masthead", vec![]),
2119                make_component("MastheadMain", "Masthead", vec![]),
2120                make_component("MastheadToggle", "Masthead", vec![]),
2121            ],
2122        };
2123        let result = ts.compute_deterministic_hierarchy(&surface, &[]);
2124        assert!(
2125            !result.contains_key("Masthead"),
2126            "All leaves → no hierarchy entry"
2127        );
2128    }
2129
2130    #[test]
2131    fn hierarchy_no_signals_empty() {
2132        let ts = TypeScript::default();
2133        let surface = ApiSurface {
2134            symbols: vec![
2135                make_component("Modal", "Modal", vec![]),
2136                make_component("ModalHeader", "Modal", vec![]),
2137            ],
2138        };
2139        let result = ts.compute_deterministic_hierarchy(&surface, &[]);
2140        assert!(result.is_empty(), "No signals → empty hierarchy");
2141    }
2142
2143    #[test]
2144    fn hierarchy_interfaces_excluded() {
2145        let ts = TypeScript::default();
2146        let surface = ApiSurface {
2147            symbols: vec![
2148                make_component("Modal", "Modal", vec![]),
2149                make_component("ModalBody", "Modal", vec![]),
2150                make_props_interface("ModalProps", "Modal", None, &["children"]),
2151            ],
2152        };
2153        let changes = vec![removed_member("ModalProps", "title")];
2154        let result = ts.compute_deterministic_hierarchy(&surface, &changes);
2155
2156        for family in result.values() {
2157            for children in family.values() {
2158                for child in children {
2159                    assert_ne!(
2160                        child.name, "ModalProps",
2161                        "Interfaces should not be hierarchy candidates"
2162                    );
2163                }
2164            }
2165        }
2166    }
2167
2168    // ── Signal 1: Prop absorption ────────────────────────────────
2169
2170    #[test]
2171    fn hierarchy_signal1_prop_absorption() {
2172        let ts = TypeScript::default();
2173        // Parent had "header" prop removed, child ModalHeader has "header" member
2174        let surface = ApiSurface {
2175            symbols: vec![
2176                make_component("Modal", "Modal", vec![]),
2177                make_component("ModalHeader", "Modal", vec![]),
2178                make_props_interface("ModalProps", "Modal", None, &["children"]),
2179                make_props_interface("ModalHeaderProps", "Modal", None, &["header", "title"]),
2180            ],
2181        };
2182        let changes = vec![
2183            removed_member("ModalProps", "header"),
2184            removed_member("ModalProps", "title"),
2185        ];
2186        let result = ts.compute_deterministic_hierarchy(&surface, &changes);
2187        let children = child_names(&result, "Modal", "Modal");
2188        assert!(
2189            children.contains(&"ModalHeader".to_string()),
2190            "ModalHeader absorbed removed props from Modal"
2191        );
2192    }
2193
2194    #[test]
2195    fn hierarchy_signal1_internally_rendered_is_prop_passed() {
2196        let ts = TypeScript::default();
2197        // Modal renders ModalHeader internally → mechanism should be "prop"
2198        let surface = ApiSurface {
2199            symbols: vec![
2200                make_component("Modal", "Modal", vec!["ModalHeader"]),
2201                make_component("ModalHeader", "Modal", vec![]),
2202                make_props_interface("ModalProps", "Modal", None, &["children"]),
2203                make_props_interface("ModalHeaderProps", "Modal", None, &["header"]),
2204            ],
2205        };
2206        let changes = vec![removed_member("ModalProps", "header")];
2207        let result = ts.compute_deterministic_hierarchy(&surface, &changes);
2208        assert_eq!(
2209            child_mechanism(&result, "Modal", "Modal", "ModalHeader"),
2210            Some("prop".to_string()),
2211            "Internally rendered child uses prop mechanism"
2212        );
2213    }
2214
2215    #[test]
2216    fn hierarchy_signal1_not_rendered_is_child() {
2217        let ts = TypeScript::default();
2218        // Modal does NOT render ModalBody → mechanism should be "child"
2219        let surface = ApiSurface {
2220            symbols: vec![
2221                make_component("Modal", "Modal", vec![]),
2222                make_component("ModalBody", "Modal", vec![]),
2223                make_props_interface("ModalProps", "Modal", None, &["children"]),
2224                make_props_interface("ModalBodyProps", "Modal", None, &["bodyContent"]),
2225            ],
2226        };
2227        let changes = vec![removed_member("ModalProps", "bodyContent")];
2228        let result = ts.compute_deterministic_hierarchy(&surface, &changes);
2229        assert_eq!(
2230            child_mechanism(&result, "Modal", "Modal", "ModalBody"),
2231            Some("child".to_string()),
2232            "Non-rendered child uses child mechanism"
2233        );
2234    }
2235
2236    // ── Signal 2: Cross-family extends ──────────────────────────
2237
2238    #[test]
2239    fn hierarchy_signal2_cross_family_extends() {
2240        let ts = TypeScript::default();
2241        // Dropdown renders Menu (cross-family). DropdownList extends MenuListProps.
2242        // Menu is a container (renders MenuItem) but does NOT render MenuList
2243        // internally — MenuList is a consumer-placed child. So DropdownList
2244        // should also be a consumer-placed child of Dropdown.
2245        let surface = ApiSurface {
2246            symbols: vec![
2247                // Menu family: Menu renders MenuItem (making it a container),
2248                // but NOT MenuList (consumer places MenuList)
2249                make_component("Menu", "Menu", vec!["MenuItem"]),
2250                make_component("MenuList", "Menu", vec![]),
2251                make_component("MenuItem", "Menu", vec![]),
2252                make_props_interface("MenuProps", "Menu", None, &["children"]),
2253                make_props_interface("MenuListProps", "Menu", None, &["items"]),
2254                make_props_interface("MenuItemProps", "Menu", None, &["label"]),
2255                // Dropdown family
2256                make_component("Dropdown", "Dropdown", vec!["Menu"]),
2257                make_component("DropdownList", "Dropdown", vec![]),
2258                make_props_interface(
2259                    "DropdownProps",
2260                    "Dropdown",
2261                    Some("MenuProps"),
2262                    &["children"],
2263                ),
2264                make_props_interface(
2265                    "DropdownListProps",
2266                    "Dropdown",
2267                    Some("MenuListProps"),
2268                    &["items"],
2269                ),
2270            ],
2271        };
2272        let result = ts.compute_deterministic_hierarchy(&surface, &[]);
2273        let children = child_names(&result, "Dropdown", "Dropdown");
2274        assert!(
2275            children.contains(&"DropdownList".to_string()),
2276            "Cross-family extends: DropdownList should be child of Dropdown"
2277        );
2278    }
2279
2280    #[test]
2281    fn hierarchy_signal2_leaf_wrapper_no_false_children() {
2282        let ts = TypeScript::default();
2283        // DropdownList extends MenuListProps but does NOT render Menu.
2284        // DropdownItem extends MenuItemProps.
2285        // DropdownList should NOT claim DropdownItem as its child
2286        // (only the root Dropdown that renders Menu should map children).
2287        let surface = ApiSurface {
2288            symbols: vec![
2289                make_component("Menu", "Menu", vec!["MenuList", "MenuItem"]),
2290                make_component("MenuList", "Menu", vec![]),
2291                make_component("MenuItem", "Menu", vec![]),
2292                make_props_interface("MenuProps", "Menu", None, &["children"]),
2293                make_props_interface("MenuListProps", "Menu", None, &["items"]),
2294                make_props_interface("MenuItemProps", "Menu", None, &["label"]),
2295                make_component("Dropdown", "Dropdown", vec!["Menu"]),
2296                make_component("DropdownList", "Dropdown", vec!["MenuList"]),
2297                make_component("DropdownItem", "Dropdown", vec![]),
2298                make_props_interface(
2299                    "DropdownProps",
2300                    "Dropdown",
2301                    Some("MenuProps"),
2302                    &["children"],
2303                ),
2304                make_props_interface(
2305                    "DropdownListProps",
2306                    "Dropdown",
2307                    Some("MenuListProps"),
2308                    &["items"],
2309                ),
2310                make_props_interface(
2311                    "DropdownItemProps",
2312                    "Dropdown",
2313                    Some("MenuItemProps"),
2314                    &["label"],
2315                ),
2316            ],
2317        };
2318        let result = ts.compute_deterministic_hierarchy(&surface, &[]);
2319        // DropdownList is a leaf wrapper — it should NOT have children
2320        let dl_children = child_names(&result, "Dropdown", "DropdownList");
2321        assert!(
2322            dl_children.is_empty(),
2323            "Leaf wrapper DropdownList should not have children"
2324        );
2325    }
2326
2327    // ── Signal 3: Internal rendering ────────────────────────────
2328
2329    #[test]
2330    fn hierarchy_signal3_internal_render_with_absorption() {
2331        let ts = TypeScript::default();
2332        // Alert renders AlertIcon internally. AlertIcon absorbed "icon" prop.
2333        let surface = ApiSurface {
2334            symbols: vec![
2335                make_component("Alert", "Alert", vec!["AlertIcon"]),
2336                make_component("AlertIcon", "Alert", vec![]),
2337                make_props_interface("AlertProps", "Alert", None, &["children"]),
2338                make_props_interface("AlertIconProps", "Alert", None, &["icon"]),
2339            ],
2340        };
2341        let changes = vec![removed_member("AlertProps", "icon")];
2342        let result = ts.compute_deterministic_hierarchy(&surface, &changes);
2343        assert_eq!(
2344            child_mechanism(&result, "Alert", "Alert", "AlertIcon"),
2345            Some("prop".to_string()),
2346            "Internally rendered child with absorption → prop mechanism"
2347        );
2348    }
2349}