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}