Skip to main content

semver_analyzer_core/diff/
mod.rs

1//! Structural diff engine for comparing two API surfaces.
2//!
3//! This module is language-agnostic. It operates on `ApiSurface` instances
4//! produced by language-specific `ApiExtractor` implementations and produces
5//! `StructuralChange` entries.
6//!
7//! Type comparison is done via canonicalized string equality — the
8//! `ApiExtractor` is responsible for normalizing types before they reach
9//! this function.
10//!
11//! ## Module structure
12//!
13//! - [`compare`] — Individual comparison functions (visibility, modifiers,
14//!   hierarchy, signatures, members).
15//! - [`rename`] — Rename detection via fingerprint matching + name similarity.
16//! - [`relocate`] — Path-based relocation detection (moved to deprecated, etc.).
17//! - [`helpers`] — Shared utility functions (change builders, summaries).
18
19mod compare;
20mod helpers;
21mod migration;
22mod relocate;
23mod rename;
24
25#[cfg(test)]
26mod tests;
27
28use crate::traits::LanguageSemantics;
29use crate::types::{ApiSurface, ChangeSubject, StructuralChange, StructuralChangeType, Symbol};
30use std::collections::{HashMap, HashSet};
31
32use compare::diff_symbol;
33use helpers::{kind_label, symbol_summary};
34use migration::detect_migrations;
35use relocate::{detect_relocations, RelocationType};
36use rename::detect_renames;
37
38/// Compare two API surfaces using language-specific semantic rules.
39///
40/// This is the core of the TD (Top-Down) pipeline. It matches symbols by
41/// `qualified_name`, then compares every field to detect additions, removals,
42/// and modifications.
43///
44/// The `semantics` parameter provides language-specific rules for:
45/// - Whether adding a member is breaking (`is_member_addition_breaking`)
46/// - How to group related symbols (`same_family`, `same_identity`)
47/// - How to rank visibility levels (`visibility_rank`)
48/// - How to parse union/literal types (`parse_union_values`)
49/// - Post-processing of the change list (`post_process`)
50///
51/// The matching pipeline is:
52/// 1. **Exact qualified_name match** — symbols at the same path are compared directly.
53/// 2. **Relocation detection** — symbols with the same canonical path (stripping
54///    `/deprecated/` and `/next/`) are matched as path moves (e.g., moved to deprecated).
55/// 3. **Rename detection** — remaining removed+added pairs are matched by type
56///    fingerprint and name similarity.
57/// 4. **Unmatched** — remaining removed symbols are reported as removed, added as added.
58///
59/// Star re-export symbols (`export * from './module'`) are filtered out.
60pub fn diff_surfaces_with_semantics<M, S>(
61    old: &ApiSurface<M>,
62    new: &ApiSurface<M>,
63    semantics: &S,
64) -> Vec<StructuralChange>
65where
66    M: Default + Clone + PartialEq,
67    S: LanguageSemantics<M>,
68{
69    let mut changes = Vec::new();
70
71    // Filter out symbols that the language wants to skip (e.g., TypeScript
72    // star re-exports `export * from '...'` in barrel files).
73    let old_symbols: Vec<&Symbol<M>> = old
74        .symbols
75        .iter()
76        .filter(|s| !semantics.should_skip_symbol(s))
77        .collect();
78    let new_symbols: Vec<&Symbol<M>> = new
79        .symbols
80        .iter()
81        .filter(|s| !semantics.should_skip_symbol(s))
82        .collect();
83
84    // Build lookup maps by qualified_name
85    let old_map: HashMap<&str, &Symbol<M>> = old_symbols
86        .iter()
87        .map(|s| (s.qualified_name.as_str(), *s))
88        .collect();
89    let new_map: HashMap<&str, &Symbol<M>> = new_symbols
90        .iter()
91        .map(|s| (s.qualified_name.as_str(), *s))
92        .collect();
93
94    // Collect removed and added symbols (not matched by exact qualified_name)
95    let removed: Vec<&Symbol<M>> = old_symbols
96        .iter()
97        .filter(|s| !new_map.contains_key(s.qualified_name.as_str()))
98        .copied()
99        .collect();
100    let added: Vec<&Symbol<M>> = new_symbols
101        .iter()
102        .filter(|s| !old_map.contains_key(s.qualified_name.as_str()))
103        .copied()
104        .collect();
105
106    // ── Phase 1: Relocation detection ────────────────────────────────
107    // Match removed+added symbols by canonical path (stripping /deprecated/
108    // and /next/). This catches "moved to deprecated" patterns and runs
109    // BEFORE rename detection to reduce the search space.
110    let (relocations, _skip_removed, _skip_added) = detect_relocations(
111        &removed,
112        &added,
113        |qname| semantics.canonical_name_for_relocation(qname),
114        |old_qname, new_qname| {
115            if let Some(label) = semantics.classify_relocation(old_qname, new_qname) {
116                match label {
117                    "moved to deprecated" => RelocationType::MovedToDeprecated,
118                    "promoted from deprecated" => RelocationType::PromotedFromDeprecated,
119                    "promoted from next" => RelocationType::PromotedFromNext,
120                    "moved to next" => RelocationType::MovedToNext,
121                    _ => RelocationType::Relocated,
122                }
123            } else {
124                RelocationType::Relocated
125            }
126        },
127    );
128
129    let relocated_old: HashSet<&str> = relocations
130        .iter()
131        .map(|r| r.old.qualified_name.as_str())
132        .collect();
133    let relocated_new: HashSet<&str> = relocations
134        .iter()
135        .map(|r| r.new.qualified_name.as_str())
136        .collect();
137
138    // Emit relocation changes and diff members of relocated symbols
139    for reloc in &relocations {
140        match reloc.relocation_type {
141            RelocationType::MovedToDeprecated => {
142                changes.push(StructuralChange {
143                    symbol: reloc.old.name.clone(),
144                    qualified_name: reloc.old.qualified_name.clone(),
145                    kind: reloc.old.kind,
146                    package: reloc.old.package.clone(),
147                    change_type: StructuralChangeType::Relocated {
148                        from: ChangeSubject::Symbol {
149                            kind: reloc.old.kind,
150                        },
151                        to: ChangeSubject::Symbol {
152                            kind: reloc.old.kind,
153                        },
154                    },
155                    before: Some(reloc.old.qualified_name.clone()),
156                    after: Some(reloc.new.qualified_name.clone()),
157                    description: format!(
158                        "{} `{}` moved to deprecated exports",
159                        kind_label(reloc.old.kind),
160                        reloc.old.name
161                    ),
162                    is_breaking: true,
163                    impact: None,
164                    migration_target: None,
165                });
166            }
167            RelocationType::PromotedFromDeprecated => {
168                changes.push(StructuralChange {
169                    symbol: reloc.old.name.clone(),
170                    qualified_name: reloc.old.qualified_name.clone(),
171                    kind: reloc.old.kind,
172                    package: reloc.old.package.clone(),
173                    change_type: StructuralChangeType::Added(ChangeSubject::Symbol {
174                        kind: reloc.old.kind,
175                    }),
176                    before: Some(reloc.old.qualified_name.clone()),
177                    after: Some(reloc.new.qualified_name.clone()),
178                    description: format!(
179                        "{} `{}` promoted from deprecated to main exports",
180                        kind_label(reloc.old.kind),
181                        reloc.old.name
182                    ),
183                    is_breaking: false,
184                    impact: None,
185                    migration_target: None,
186                });
187            }
188            RelocationType::PromotedFromNext => {
189                changes.push(StructuralChange {
190                    symbol: reloc.old.name.clone(),
191                    qualified_name: reloc.old.qualified_name.clone(),
192                    kind: reloc.old.kind,
193                    package: reloc.old.package.clone(),
194                    change_type: StructuralChangeType::Renamed {
195                        from: ChangeSubject::Symbol { kind: reloc.old.kind },
196                        to: ChangeSubject::Symbol { kind: reloc.new.kind },
197                    },
198                    before: Some(reloc.old.qualified_name.clone()),
199                    after: Some(reloc.new.qualified_name.clone()),
200                    description: format!(
201                        "{} `{}` promoted from next (preview) to main exports — import path changed",
202                        kind_label(reloc.old.kind),
203                        reloc.old.name
204                    ),
205                    is_breaking: true,
206                    impact: None,
207                    migration_target: None,
208                });
209            }
210            RelocationType::MovedToNext => {
211                changes.push(StructuralChange {
212                    symbol: reloc.old.name.clone(),
213                    qualified_name: reloc.old.qualified_name.clone(),
214                    kind: reloc.old.kind,
215                    package: reloc.old.package.clone(),
216                    change_type: StructuralChangeType::Renamed {
217                        from: ChangeSubject::Symbol {
218                            kind: reloc.old.kind,
219                        },
220                        to: ChangeSubject::Symbol {
221                            kind: reloc.new.kind,
222                        },
223                    },
224                    before: Some(reloc.old.qualified_name.clone()),
225                    after: Some(reloc.new.qualified_name.clone()),
226                    description: format!(
227                        "{} `{}` moved to next (preview) exports — import path changed",
228                        kind_label(reloc.old.kind),
229                        reloc.old.name
230                    ),
231                    is_breaking: true,
232                    impact: None,
233                    migration_target: None,
234                });
235            }
236            RelocationType::Relocated => {
237                // General restructuring — check whether the consumer-facing
238                // import path changed. If so, this is breaking.
239                let old_import = reloc
240                    .old
241                    .import_path
242                    .as_ref()
243                    .or(reloc.old.package.as_ref());
244                let new_import = reloc
245                    .new
246                    .import_path
247                    .as_ref()
248                    .or(reloc.new.package.as_ref());
249
250                if old_import.is_some() && old_import != new_import {
251                    let old_ip = old_import.cloned().unwrap_or_default();
252                    let new_ip = new_import.cloned().unwrap_or_default();
253                    changes.push(StructuralChange {
254                        symbol: reloc.old.name.clone(),
255                        qualified_name: reloc.old.qualified_name.clone(),
256                        kind: reloc.old.kind,
257                        package: reloc.old.package.clone(),
258                        change_type: StructuralChangeType::Relocated {
259                            from: ChangeSubject::Symbol {
260                                kind: reloc.old.kind,
261                            },
262                            to: ChangeSubject::Symbol {
263                                kind: reloc.new.kind,
264                            },
265                        },
266                        before: Some(old_ip),
267                        after: Some(new_ip),
268                        description: format!(
269                            "{} `{}` moved from `{}` to `{}`",
270                            kind_label(reloc.old.kind),
271                            reloc.old.name,
272                            old_import.map(|s| s.as_str()).unwrap_or("?"),
273                            new_import.map(|s| s.as_str()).unwrap_or("?"),
274                        ),
275                        is_breaking: true,
276                        impact: None,
277                        migration_target: None,
278                    });
279                }
280                // Otherwise non-breaking restructuring — just diff members below.
281            }
282        }
283
284        // Diff members of relocated symbols to catch property-level changes
285        // (e.g., ChipProps lost the `component` prop when moved to deprecated)
286        diff_symbol(reloc.old, reloc.new, &mut changes, semantics);
287    }
288
289    // ── Phase 2: Rename detection ────────────────────────────────────
290    // Match remaining removed+added pairs by type fingerprint + name similarity.
291    // Relocated symbols are excluded.
292    //
293    // Also exclude removed symbols whose name exists in the NEW API surface
294    // with the SAME import path — these are migrations (e.g., SelectOptionProps
295    // moved from /deprecated/ to main where a same-named type already exists),
296    // not renames. They'll be handled by Phase 5 (migration detection).
297    //
298    // Symbols with the same name but DIFFERENT import paths (e.g., Chart
299    // moved from root to /victory) are kept — they need to pass through
300    // rename detection to emit Relocated changes.
301    let new_by_name: HashMap<&str, Vec<&Symbol<M>>> = {
302        let mut map: HashMap<&str, Vec<&Symbol<M>>> = HashMap::new();
303        for s in new_symbols.iter() {
304            map.entry(s.name.as_str()).or_default().push(s);
305        }
306        map
307    };
308    let remaining_removed: Vec<&Symbol<M>> = removed
309        .iter()
310        .filter(|s| !relocated_old.contains(s.qualified_name.as_str()))
311        .filter(|s| {
312            // Keep if no same-named symbol exists in new API
313            let Some(new_syms) = new_by_name.get(s.name.as_str()) else {
314                return true;
315            };
316            // Keep if the same-named symbol has a DIFFERENT import path
317            // (needs Relocated detection, not migration).
318            // Use canonical_name_for_relocation to treat lifecycle modifiers
319            // (e.g., /deprecated/, /next/) as equivalent to the base path —
320            // moving from @pkg/deprecated to @pkg is a migration, not a
321            // consumer-visible import change.
322            let old_import = s.import_path.as_ref().or(s.package.as_ref());
323            let old_base = old_import.map(|p| semantics.canonical_name_for_relocation(p));
324            !new_syms.iter().any(|ns| {
325                let new_import = ns.import_path.as_ref().or(ns.package.as_ref());
326                let new_base = new_import.map(|p| semantics.canonical_name_for_relocation(p));
327                old_base == new_base
328            })
329        })
330        .copied()
331        .collect();
332    let remaining_added: Vec<&Symbol<M>> = added
333        .iter()
334        .filter(|s| !relocated_new.contains(s.qualified_name.as_str()))
335        .copied()
336        .collect();
337
338    let renames = detect_renames(
339        &remaining_removed,
340        &remaining_added,
341        |a, b| semantics.same_family(a, b),
342        semantics.primitive_type_names(),
343    );
344
345    // ── Rename validation ──────────────────────────────────────────────
346    //
347    // All rename quality checks are consolidated here. Each rename candidate
348    // is either accepted or rejected. Rejected pairs become Removed + Added
349    // in the final output (for LLM-assisted fixing instead of mechanical
350    // codemod).
351    //
352    // Checks applied in order:
353    // 1. Type compatibility — reject structurally incompatible renames
354    // 2. Cross-family type_alias/enum guard — require sibling validation
355    //
356    // See design/rename-detector-verification.md for the full dataset.
357    let (validated_renames, _rejected_renames) = validate_renames(&renames, semantics);
358
359    // Only validated renames go into the renamed sets.
360    let mut renamed_old: HashSet<&str> = validated_renames
361        .iter()
362        .map(|r| r.old.qualified_name.as_str())
363        .collect();
364    let mut renamed_new: HashSet<&str> = validated_renames
365        .iter()
366        .map(|r| r.new.qualified_name.as_str())
367        .collect();
368
369    // Build directory rename mappings from cross-directory renames.
370    // If Phase 2 matched `TextVariants` (in `Text/`) → `ContentVariants` (in `Content/`),
371    // then `Text/` → `Content/` is a known directory migration. This lets Phase 5
372    // (detect_migrations) search across directories for unmatched removals.
373    //
374    // Multiple renames from the same source directory may point to different targets
375    // (e.g., `TextVariants` → `Content/`, `TextListVariants` → `Toolbar/`), so we
376    // collect all unique target directories per source.
377    let dir_renames: HashMap<String, Vec<String>> = {
378        let mut map: HashMap<String, Vec<String>> = HashMap::new();
379        for rm in &renames {
380            let old_dir = rm
381                .old
382                .file
383                .parent()
384                .map(|p| p.to_string_lossy().to_string());
385            let new_dir = rm
386                .new
387                .file
388                .parent()
389                .map(|p| p.to_string_lossy().to_string());
390            if let (Some(od), Some(nd)) = (old_dir, new_dir) {
391                if od != nd {
392                    tracing::debug!(
393                        old_dir = %od,
394                        new_dir = %nd,
395                        via = %rm.old.name,
396                        "Directory rename mapping from Phase 2"
397                    );
398                    let targets = map.entry(od).or_default();
399                    if !targets.contains(&nd) {
400                        targets.push(nd);
401                    }
402                }
403            }
404        }
405        map
406    };
407
408    // Emit rename changes (type-compatible and validated only —
409    // incompatible pairs and unvalidated cross-family type_alias renames
410    // stay as Removed + Added for LLM-assisted fixing)
411    for rm in &validated_renames {
412        // Same export name, different file path. Check whether the
413        // consumer-facing import path changed (e.g., a symbol moved from
414        // `@patternfly/react-charts` to `@patternfly/react-charts/victory`).
415        if rm.old.name == rm.new.name {
416            let old_import = rm.old.import_path.as_ref().or(rm.old.package.as_ref());
417            let new_import = rm.new.import_path.as_ref().or(rm.new.package.as_ref());
418
419            if old_import.is_some() && old_import != new_import {
420                // Import path changed — this is consumer-visible.
421                let old_ip = old_import.cloned().unwrap_or_default();
422                let new_ip = new_import.cloned().unwrap_or_default();
423                tracing::debug!(
424                    name = %rm.old.name,
425                    from = %old_ip,
426                    to = %new_ip,
427                    "Detected import path relocation (same name, different import path)"
428                );
429                changes.push(StructuralChange {
430                    symbol: rm.old.name.clone(),
431                    qualified_name: rm.old.qualified_name.clone(),
432                    kind: rm.old.kind,
433                    package: rm.old.package.clone(),
434                    change_type: StructuralChangeType::Relocated {
435                        from: ChangeSubject::Symbol { kind: rm.old.kind },
436                        to: ChangeSubject::Symbol { kind: rm.new.kind },
437                    },
438                    before: Some(old_ip),
439                    after: Some(new_ip),
440                    description: format!(
441                        "{} `{}` moved from `{}` to `{}`",
442                        kind_label(rm.old.kind),
443                        rm.old.name,
444                        old_import.map(|s| s.as_str()).unwrap_or("?"),
445                        new_import.map(|s| s.as_str()).unwrap_or("?"),
446                    ),
447                    is_breaking: true,
448                    impact: None,
449                    migration_target: None,
450                });
451                // Also diff members to catch property-level changes
452                diff_symbol(rm.old, rm.new, &mut changes, semantics);
453            } else {
454                tracing::trace!(
455                    name = %rm.old.name,
456                    from = %rm.old.qualified_name,
457                    to = %rm.new.qualified_name,
458                    "Skipping no-op rename (moved without import path change)"
459                );
460                // Still diff members — path changed but import didn't
461                diff_symbol(rm.old, rm.new, &mut changes, semantics);
462            }
463            continue;
464        }
465
466        changes.push(StructuralChange {
467            symbol: rm.old.name.clone(),
468            qualified_name: rm.old.qualified_name.clone(),
469            kind: rm.old.kind,
470            package: rm.old.package.clone(),
471            change_type: StructuralChangeType::Renamed {
472                from: ChangeSubject::Symbol { kind: rm.old.kind },
473                to: ChangeSubject::Symbol { kind: rm.new.kind },
474            },
475            before: Some(rm.old.name.clone()),
476            after: Some(rm.new.name.clone()),
477            description: format!(
478                "Exported {} `{}` was renamed to `{}`",
479                kind_label(rm.old.kind),
480                rm.old.name,
481                rm.new.name
482            ),
483            is_breaking: true,
484            impact: None,
485            migration_target: None,
486        });
487    }
488
489    // ── Phase 2b: Token rename detection ────────────────────────────
490    // Constants/variables (like design tokens) can't be matched by type
491    // fingerprinting because they all share the same shape. Instead, split
492    // names on `_`, lowercase, and match by segment set overlap (Jaccard).
493    {
494        let token_remaining_removed: Vec<&Symbol<M>> = removed
495            .iter()
496            .filter(|s| {
497                !relocated_old.contains(s.qualified_name.as_str())
498                    && !renamed_old.contains(s.qualified_name.as_str())
499            })
500            .copied()
501            .collect();
502        let token_remaining_added: Vec<&Symbol<M>> = added
503            .iter()
504            .filter(|s| {
505                !relocated_new.contains(s.qualified_name.as_str())
506                    && !renamed_new.contains(s.qualified_name.as_str())
507            })
508            .copied()
509            .collect();
510
511        let token_renames =
512            rename::detect_token_renames(&token_remaining_removed, &token_remaining_added, |sym| {
513                semantics.extract_rename_fallback_key(sym)
514            });
515
516        for rm in &token_renames {
517            renamed_old.insert(rm.old.qualified_name.as_str());
518            renamed_new.insert(rm.new.qualified_name.as_str());
519
520            changes.push(StructuralChange {
521                symbol: rm.old.name.clone(),
522                qualified_name: rm.old.qualified_name.clone(),
523                kind: rm.old.kind,
524                package: rm.old.package.clone(),
525                change_type: StructuralChangeType::Renamed {
526                    from: ChangeSubject::Symbol { kind: rm.old.kind },
527                    to: ChangeSubject::Symbol { kind: rm.new.kind },
528                },
529                // Use full symbol_summary (includes CSS var name in the
530                // signature) so downstream CSS prefix detection can extract
531                // the actual old/new CSS variable prefixes.
532                before: Some(symbol_summary(rm.old)),
533                after: Some(symbol_summary(rm.new)),
534                description: format!(
535                    "Exported {} `{}` was renamed to `{}`",
536                    kind_label(rm.old.kind),
537                    rm.old.name,
538                    rm.new.name
539                ),
540                is_breaking: true,
541                impact: None,
542                migration_target: None,
543            });
544        }
545    }
546
547    // ── Phase 3: Unmatched symbols ───────────────────────────────────
548    // Emit remaining removed symbols (not relocated, not renamed)
549    for sym in &removed {
550        if relocated_old.contains(sym.qualified_name.as_str())
551            || renamed_old.contains(sym.qualified_name.as_str())
552        {
553            continue;
554        }
555        changes.push(StructuralChange {
556            symbol: sym.name.clone(),
557            qualified_name: sym.qualified_name.clone(),
558            kind: sym.kind,
559            package: sym.package.clone(),
560            change_type: StructuralChangeType::Removed(ChangeSubject::Symbol { kind: sym.kind }),
561            before: Some(symbol_summary(sym)),
562            after: None,
563            description: format!(
564                "Exported {} `{}` was removed",
565                kind_label(sym.kind),
566                sym.name
567            ),
568            is_breaking: true,
569            impact: None,
570            migration_target: None,
571        });
572    }
573
574    // Emit remaining added symbols (not relocated, not renamed)
575    for sym in &added {
576        if relocated_new.contains(sym.qualified_name.as_str())
577            || renamed_new.contains(sym.qualified_name.as_str())
578        {
579            continue;
580        }
581        changes.push(StructuralChange {
582            symbol: sym.name.clone(),
583            qualified_name: sym.qualified_name.clone(),
584            kind: sym.kind,
585            package: sym.package.clone(),
586            change_type: StructuralChangeType::Added(ChangeSubject::Symbol { kind: sym.kind }),
587            before: None,
588            after: Some(symbol_summary(sym)),
589            description: format!("Exported {} `{}` was added", kind_label(sym.kind), sym.name),
590            is_breaking: false,
591            impact: None,
592            migration_target: None,
593        });
594    }
595
596    // ── Phase 4: Compare matched symbols ─────────────────────────────
597    // Symbols that matched by exact qualified_name — diff their contents.
598    for sym_old in &old_symbols {
599        if let Some(sym_new) = new_map.get(sym_old.qualified_name.as_str()) {
600            diff_symbol(sym_old, sym_new, &mut changes, semantics);
601        }
602    }
603
604    // ── Phase 5: Structural migration detection ────────────────────────
605    // For removed interfaces/classes, look for surviving or added interfaces
606    // in the same component directory with significant member name overlap.
607    // This detects "merge child into parent" and "same-name replacement"
608    // patterns and annotates the existing SymbolRemoved changes with
609    // migration target metadata.
610    {
611        let final_removed: Vec<&Symbol<M>> = removed
612            .iter()
613            .filter(|s| {
614                !relocated_old.contains(s.qualified_name.as_str())
615                    && !renamed_old.contains(s.qualified_name.as_str())
616            })
617            .copied()
618            .collect();
619
620        let migrations = detect_migrations(
621            &final_removed,
622            &old_symbols,
623            &new_symbols,
624            semantics,
625            &dir_renames,
626        );
627
628        // Annotate existing Removed(Symbol) changes with migration targets.
629        // MigrationSuggested is now represented as Removed(Symbol) with migration_target set.
630        for mig in &migrations {
631            for change in changes.iter_mut() {
632                if change.qualified_name == mig.removed.qualified_name
633                    && matches!(
634                        change.change_type,
635                        StructuralChangeType::Removed(ChangeSubject::Symbol { .. })
636                    )
637                {
638                    change.migration_target = Some(mig.target.clone());
639                    // Enrich the description with the full migration recipe
640                    // so the rule message gives the LLM actionable context.
641                    let mut matching_names: Vec<&str> = mig
642                        .target
643                        .matching_members
644                        .iter()
645                        .map(|m| m.old_name.as_str())
646                        .collect();
647                    matching_names.sort();
648                    let mut removed_names: Vec<&str> = mig
649                        .target
650                        .removed_only_members
651                        .iter()
652                        .map(|s| s.as_str())
653                        .collect();
654                    removed_names.sort();
655                    let base = change.description.trim_end_matches(" was removed");
656
657                    // When removed and replacement have the same name but live
658                    // in different packages or subpaths (e.g., SelectOptionProps
659                    // moved from /deprecated to the main package), add explicit
660                    // import guidance so the LLM changes the import source
661                    // rather than creating a local replacement type.
662                    let import_hint = if mig.target.removed_symbol == mig.target.replacement_symbol
663                        && mig.target.removed_qualified_name
664                            != mig.target.replacement_qualified_name
665                    {
666                        // Derive import paths from qualified names to detect
667                        // subpath differences (e.g., /deprecated/) that the
668                        // package fields alone may not distinguish.
669                        let old_import = semantics.derive_import_subpath(
670                            mig.target.removed_package.as_deref(),
671                            &mig.target.removed_qualified_name,
672                        );
673                        let new_import = semantics.derive_import_subpath(
674                            mig.target.replacement_package.as_deref(),
675                            &mig.target.replacement_qualified_name,
676                        );
677
678                        if old_import != new_import {
679                            format!(
680                                "\n  Import change: {}",
681                                semantics.format_import_change(
682                                    &mig.target.removed_symbol,
683                                    &old_import,
684                                    &new_import,
685                                ),
686                            )
687                        } else {
688                            String::new()
689                        }
690                    } else {
691                        String::new()
692                    };
693
694                    let label = semantics.member_label();
695                    let mut desc = format!(
696                        "{} was removed — migrate to `{}`.{}\n  Matching {} (use on `{}` instead): {}",
697                        base,
698                        mig.target.replacement_symbol,
699                        import_hint,
700                        label,
701                        mig.target.replacement_symbol,
702                        matching_names.join(", "),
703                    );
704                    if !removed_names.is_empty() {
705                        desc.push_str(&format!(
706                            "\n  Removed {} with no direct equivalent: {}\
707                             \n  NOTE: Only address removed {} that your code actually uses. \
708                             If not referenced in the file, ignore it.",
709                            label,
710                            removed_names.join(", "),
711                            label,
712                        ));
713                    }
714                    // When the base type (extends clause) changed, warn that
715                    // inherited members may no longer be available. The LLM
716                    // knows what members React.HTMLProps provides vs MenuItemProps.
717                    if mig.target.old_extends.is_some() || mig.target.new_extends.is_some() {
718                        let old_ext = mig.target.old_extends.as_deref().unwrap_or("(none)");
719                        let new_ext = mig.target.new_extends.as_deref().unwrap_or("(none)");
720                        desc.push_str(&format!(
721                            "\n  Base type changed: {} → {}. \
722                             Inherited members from the old base type are no longer available.",
723                            old_ext, new_ext,
724                        ));
725                    }
726                    change.description = desc;
727                    break;
728                }
729            }
730        }
731    }
732
733    // ── Phase 6: Language-specific post-processing ────────────────────
734    // Each language can clean up the change list. For TypeScript, this
735    // deduplicates default export changes when a named sibling exists.
736    semantics.post_process(&mut changes);
737
738    changes
739}
740
741/// Compare two API surfaces using minimal semantics (no language-specific rules).
742///
743/// For language-aware diffing, use `diff_surfaces_with_semantics` instead.
744pub fn diff_surfaces<M: Default + Clone + PartialEq>(
745    old: &ApiSurface<M>,
746    new: &ApiSurface<M>,
747) -> Vec<StructuralChange> {
748    diff_surfaces_with_semantics(old, new, &MinimalSemantics)
749}
750
751/// Minimal semantics for testing the diff engine without language-specific rules.
752///
753/// Returns conservative defaults: no member additions are breaking,
754/// symbols in the same directory are the same family, identity is by name only.
755/// No language-specific filtering, relocation detection, or import subpath logic.
756///
757/// Tests that need language-specific behavior (star re-export filtering,
758/// deprecated/next path handling) should use `diff_surfaces_with_semantics`
759/// with a semantics impl that includes those behaviors.
760pub(crate) struct MinimalSemantics;
761
762impl<M: Default + Clone + PartialEq> LanguageSemantics<M> for MinimalSemantics {
763    fn is_member_addition_breaking(&self, _container: &Symbol<M>, _member: &Symbol<M>) -> bool {
764        false
765    }
766
767    fn same_family(&self, a: &Symbol<M>, b: &Symbol<M>) -> bool {
768        // Same directory = same family
769        let a_dir = a
770            .file
771            .parent()
772            .map(|p| p.to_string_lossy().to_string())
773            .unwrap_or_default();
774        let b_dir = b
775            .file
776            .parent()
777            .map(|p| p.to_string_lossy().to_string())
778            .unwrap_or_default();
779        a_dir == b_dir
780    }
781
782    fn same_identity(&self, a: &Symbol<M>, b: &Symbol<M>) -> bool {
783        a.name == b.name
784    }
785
786    fn visibility_rank(&self, v: crate::types::Visibility) -> u8 {
787        helpers::visibility_rank(v)
788    }
789}
790
791// ── Rename validation ───────────────────────────────────────────────────
792//
793// Consolidates all rename quality checks in one place. Each candidate
794// rename is either accepted or rejected based on:
795//
796// 1. Type compatibility — structurally incompatible types are rejected
797//    (e.g., SplitButtonOptions → ReactNode[])
798// 2. Cross-family type_alias/enum guard — for cross-family type aliases
799//    and enums, require at least one sibling rename between the same two
800//    directories to validate the match
801
802fn validate_renames<'a, M, S>(
803    renames: &'a [rename::RenameMatch<'a, M>],
804    semantics: &S,
805) -> (
806    Vec<&'a rename::RenameMatch<'a, M>>,
807    Vec<&'a rename::RenameMatch<'a, M>>,
808)
809where
810    M: Default + Clone + PartialEq,
811    S: LanguageSemantics<M>,
812{
813    let mut accepted = Vec::new();
814    let mut rejected = Vec::new();
815
816    for rm in renames {
817        // ── Check 1: Type compatibility ────────────────────────────────
818        let old_rt = rm
819            .old
820            .signature
821            .as_ref()
822            .and_then(|s| s.return_type.as_deref());
823        let new_rt = rm
824            .new
825            .signature
826            .as_ref()
827            .and_then(|s| s.return_type.as_deref());
828
829        let types_match = match (old_rt, new_rt) {
830            (Some(o), Some(n)) => {
831                compare::types_structurally_similar(o, n, semantics.primitive_type_names())
832            }
833            _ => true, // No type info → assume compatible
834        };
835
836        if !types_match {
837            tracing::info!(
838                old = %rm.old.name,
839                new = %rm.new.name,
840                old_type = old_rt.unwrap_or("?"),
841                new_type = new_rt.unwrap_or("?"),
842                "Rename rejected: type-incompatible"
843            );
844            rejected.push(rm);
845            continue;
846        }
847
848        // ── Check 2: Cross-family sibling guard ─────────────────────────
849        //
850        // For ANY cross-family rename that passed the 0.50 similarity
851        // threshold, require a sibling rename between the same two
852        // directories to validate the match. Without this, high-similarity
853        // cross-family matches slip through:
854        // - TextListItemVariants → HelperTextItemVariant (0.714, Enum)
855        // - TextProps → TruncateProps (0.69, Interface)
856        // - DropdownToggleActionProps → DrawerPanelDescriptionProps (Interface)
857        // - PageHeader → PageBody (Constant)
858        //
859        // True cross-family renames like TextVariants → ContentVariants
860        // pass because they have the sibling TextContent → Content.
861
862        // Skip same-name matches — these are import path relocations, not
863        // cross-family renames. They're handled by the Relocated logic downstream.
864        if rm.old.name != rm.new.name && !semantics.same_family(rm.old, rm.new) {
865            let old_dir = rm
866                .old
867                .file
868                .parent()
869                .map(|p| p.to_string_lossy().to_string())
870                .unwrap_or_default();
871            let new_dir = rm
872                .new
873                .file
874                .parent()
875                .map(|p| p.to_string_lossy().to_string())
876                .unwrap_or_default();
877
878            let has_sibling = renames.iter().any(|other| {
879                if other.old.qualified_name == rm.old.qualified_name {
880                    return false;
881                }
882                let other_old_dir = other
883                    .old
884                    .file
885                    .parent()
886                    .map(|p| p.to_string_lossy().to_string())
887                    .unwrap_or_default();
888                let other_new_dir = other
889                    .new
890                    .file
891                    .parent()
892                    .map(|p| p.to_string_lossy().to_string())
893                    .unwrap_or_default();
894                old_dir == other_old_dir && new_dir == other_new_dir
895            });
896
897            if !has_sibling {
898                tracing::info!(
899                    old = %rm.old.name,
900                    new = %rm.new.name,
901                    old_dir = %old_dir,
902                    new_dir = %new_dir,
903                    "Rename rejected: cross-family type_alias/enum with no sibling rename"
904                );
905                rejected.push(rm);
906                continue;
907            }
908        }
909
910        accepted.push(rm);
911    }
912
913    (accepted, rejected)
914}