semver-analyzer-core 0.0.3

Core types, traits, and diff engine for the semver-analyzer
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
//! Trait definitions for language-pluggable analysis.
//!
//! Adding a new language means implementing these traits. The orchestrator,
//! diff engine, and output format are language-agnostic and reused unchanged.
//!
//! ## Trait ownership
//!
//! | Trait | Used by | Per-language? |
//! |---|---|---|
//! | `Language` | TD + BU | Yes (unified analysis pipeline) |
//! | `BehaviorAnalyzer` | BU | No (language-agnostic, LLM-based) |

use crate::types::{
    ApiSurface, BehavioralChangeKind, BodyAnalysisResult, BreakingVerdict, Caller, ChangeSubject,
    ChangedFunction, EvidenceType, ExpectedChild, FunctionSpec, Reference, SdPipelineResult,
    StructuralChange, StructuralChangeType, Symbol, SymbolKind, TestDiff, TestFile, Visibility,
};
use anyhow::Result;
use serde::{de::DeserializeOwned, Serialize};
use std::collections::{BTreeSet, HashMap};
use std::fmt::Debug;
use std::path::Path;

// ── BU Traits (language-agnostic, LLM-based) ───────────────────────────

/// Analyze behavioral changes via LLM-based spec inference.
///
/// Language-agnostic: the function body and signature are passed as
/// strings. The LLM generates template-constrained `FunctionSpec`
/// objects, which are compared mechanically (Tier 1) or via LLM
/// fallback (Tier 2).
///
/// Implementations may use:
/// - Direct LLM API calls (OpenAI, Anthropic, etc.)
/// - `goose run --no-session -q -t "..."`
/// - `opencode run "..."`
/// - Any other agent CLI via `--llm-command`
pub trait BehaviorAnalyzer {
    /// Infer a function's behavioral spec from its body alone.
    ///
    /// Lower confidence than `infer_spec_with_test_context` because
    /// the LLM has no grounded examples of expected behavior.
    fn infer_spec(&self, function_body: &str, signature: &str) -> Result<FunctionSpec>;

    /// Infer a spec with additional context from the test file.
    ///
    /// The test assertions give the LLM concrete examples of expected
    /// behavior — reducing hallucination compared to body-only inference.
    fn infer_spec_with_test_context(
        &self,
        function_body: &str,
        signature: &str,
        test_context: &TestDiff,
    ) -> Result<FunctionSpec>;

    /// Compare two specs and determine if the change is breaking.
    ///
    /// Uses a two-tier approach:
    /// - Tier 1: Structural comparison on `FunctionSpec` fields
    /// - Tier 2: LLM fallback for `notes` diffs and ambiguous matches
    fn specs_are_breaking(&self, old: &FunctionSpec, new: &FunctionSpec)
        -> Result<BreakingVerdict>;

    /// Check whether a caller propagates a behavioral break from a callee.
    ///
    /// Given a caller's body/signature and evidence of a behavioral
    /// break in a callee it invokes, determine whether the caller's
    /// observable behavior actually changes. The caller might absorb
    /// the break by:
    ///   - Ignoring the callee's return value
    ///   - Catching and handling the callee's new error behavior
    ///   - Only invoking the callee on code paths that don't trigger
    ///     the behavioral change
    ///   - Applying its own validation that masks the change
    ///
    /// Returns true if the break propagates (caller IS affected),
    /// false if the caller absorbs it (NOT affected).
    fn check_propagation(
        &self,
        caller_body: &str,
        caller_signature: &str,
        callee_name: &str,
        evidence_description: &str,
    ) -> Result<bool>;
}

// ── Language abstraction traits (multi-language architecture) ────────────
//
// These traits define the integration point for multi-language support.
// See `design/01-traits.md` for detailed documentation.

/// Language-specific semantic rules consumed by the diff engine.
///
/// These encode the places where "is this breaking?" or "are these related?"
/// differ fundamentally by language. The diff engine calls these methods
/// instead of hardcoding language-specific rules.
pub trait LanguageSemantics {
    /// Is adding this member to this container a breaking change?
    ///
    /// This is the single rule that differs most fundamentally by language:
    /// - TypeScript: breaking only if the member is required (non-optional).
    /// - Go: ALWAYS breaking for interfaces (all implementors must add it).
    /// - Java: breaking for abstract methods, not for default methods.
    /// - C#: breaking for abstract members on interfaces.
    /// - Python: breaking for abstract methods on Protocol/ABC.
    fn is_member_addition_breaking(&self, container: &Symbol, member: &Symbol) -> bool;

    /// Are these two symbols part of the same logical family/group?
    ///
    /// Used to scope migration detection. When a symbol is removed, only
    /// symbols in the same family are considered as potential absorption targets.
    ///
    /// - TypeScript/React: same component directory
    /// - Go: same package
    /// - Java: same package
    /// - Python: same module
    fn same_family(&self, a: &Symbol, b: &Symbol) -> bool;

    /// Are these two symbols the same concept, possibly at different paths?
    ///
    /// When true, migration detection does a full member comparison (all members,
    /// not just newly-added ones) because the candidate is assumed to be a direct
    /// replacement for the removed symbol.
    ///
    /// Resolves companion types linked by naming convention:
    /// - TypeScript: `Button` and `ButtonProps` (component + its props interface)
    /// - Go: `Client` and `ClientOptions` (struct + its configuration)
    /// - Java: `UserService` and `UserServiceImpl` (interface + implementation)
    fn same_identity(&self, a: &Symbol, b: &Symbol) -> bool;

    /// Numeric rank for a visibility level (higher = more visible).
    ///
    /// Used to determine if visibility was reduced (breaking) or increased.
    /// The ordering differs by language:
    /// - TypeScript: Private(0) < Internal(1) < Protected(1) < Public(2) < Exported(3)
    /// - Java: Private(0) < PackagePrivate(1) < Protected(2) < Public(3)
    /// - Go: Internal(0) < Exported(1)
    fn visibility_rank(&self, v: Visibility) -> u8;

    /// Parse union/constrained type values for fine-grained diffing.
    ///
    /// TypeScript: parse `'primary' | 'secondary' | 'danger'`.
    /// Python: parse `Literal['a', 'b']`.
    /// Most other languages return `None`.
    fn parse_union_values(&self, _type_str: &str) -> Option<BTreeSet<String>> {
        None
    }

    /// Whether a return type string represents an async wrapper.
    ///
    /// Used by the diff engine to detect sync→async and async→sync changes,
    /// which are always breaking regardless of the inner type.
    ///
    /// TypeScript/JavaScript: `Promise<T>`
    /// Python: `Coroutine[...]`, `Awaitable[...]`
    /// Java: `CompletableFuture<T>`, `Future<T>`
    /// Go: returns `false` (async handled via goroutines, not return types)
    fn is_async_wrapper(&self, _type_str: &str) -> bool {
        false
    }

    /// Format an import/use statement change hint for migration descriptions.
    ///
    /// When a symbol is renamed across packages, the diff engine includes
    /// import guidance so consumers know to update their import paths.
    ///
    /// TypeScript: `"replace \`import { X } from 'old-pkg'\` with \`import { X } from 'new-pkg'\`"`
    /// Go: `"replace \`\"old/pkg\"\` with \`\"new/pkg\"\`"`
    /// Default: generic format without language-specific syntax.
    fn format_import_change(&self, symbol: &str, old_path: &str, new_path: &str) -> String {
        format!(
            "replace import of `{}` from `{}` with `{}`",
            symbol, old_path, new_path,
        )
    }

    /// Post-process the change list before returning from diff_surfaces.
    ///
    /// TypeScript: dedup default export changes.
    /// Most languages: no-op.
    fn post_process(&self, _changes: &mut Vec<StructuralChange>) {}

    /// If this language supports component hierarchy inference (e.g., React,
    /// Vue, Django templates), return the hierarchy semantics implementation.
    ///
    /// The orchestrator uses this to prepare data for LLM hierarchy inference.
    /// The trait is NOT responsible for LLM calls or prompt construction.
    fn hierarchy(&self) -> Option<&dyn HierarchySemantics> {
        None
    }

    /// If this language supports LLM-based rename inference (e.g., CSS
    /// physical→logical property renames, interface rename mappings),
    /// return the rename semantics implementation.
    ///
    /// The orchestrator uses this to prepare data for LLM rename inference.
    /// The trait is NOT responsible for LLM calls or prompt construction.
    fn renames(&self) -> Option<&dyn RenameSemantics> {
        None
    }

    /// If this language has deterministic body-level analysis (e.g., JSX diff,
    /// CSS variable scanning for TypeScript), return the body analysis
    /// implementation.
    ///
    /// The orchestrator calls this during BU Phase 1 to detect behavioral
    /// breaks from function body changes without LLM assistance.
    fn body_analyzer(&self) -> Option<&dyn BodyAnalysisSemantics> {
        None
    }
}

// ── Optional capability traits ──────────────────────────────────────────
//
// These traits represent optional analysis capabilities that some languages
// support. They are accessed via optional accessors on `LanguageSemantics`.
// The orchestrator checks for their presence and conditionally runs the
// corresponding analysis steps.

/// Deterministic data preparation for component hierarchy inference.
///
/// Languages with component composition models (React, Vue, Django, etc.)
/// implement this to tell the orchestrator what files belong to a component
/// family and how families relate to each other.
///
/// The orchestrator uses `same_family` for symbol grouping, then these
/// methods for data preparation. The LLM call itself stays in the orchestrator.
///
/// TODO: Reconsider — the methods that take repo/git_ref currently require
/// language impls to know about git. A future refactor should have the
/// orchestrator own all git plumbing and pass content to pure-logic methods.
pub trait HierarchySemantics {
    /// Get file paths belonging to a component family directory.
    ///
    /// Given a family name (e.g., "Dropdown"), returns relative paths to
    /// all source files in that family. Used to read content for the LLM prompt.
    fn family_source_paths(&self, repo: &Path, git_ref: &str, family_name: &str) -> Vec<String>;

    /// Get a human-readable family name from a group of symbols.
    ///
    /// TypeScript/React: extracts the component directory name
    /// (e.g., "Dropdown" from "packages/react-core/src/components/Dropdown/...")
    fn family_name_from_symbols(&self, symbols: &[&Symbol]) -> Option<String>;

    /// Detect cross-family relationships (e.g., React context imports).
    ///
    /// Returns pairs of (consumer_family, provider_family, relationship_name).
    /// Used to include related component signatures in the LLM prompt.
    fn cross_family_relationships(
        &self,
        repo: &Path,
        git_ref: &str,
    ) -> Vec<(String, String, String)>;

    /// Read related component signatures for cross-family context.
    ///
    /// Given a provider family and the context/relationship names that
    /// link it to a consumer, returns relevant source content to include
    /// in the LLM prompt.
    fn related_family_content(
        &self,
        repo: &Path,
        git_ref: &str,
        family_name: &str,
        relationship_names: &[String],
    ) -> Option<String>;

    /// Whether a symbol is a candidate for hierarchy inference.
    ///
    /// The orchestrator calls this to filter symbols when grouping into
    /// families. Only candidates are counted toward the minimum threshold.
    ///
    /// TypeScript/React: PascalCase Variable/Class/Function/Constant
    /// (React components are PascalCase functions or classes).
    fn is_hierarchy_candidate(&self, sym: &Symbol) -> bool;

    /// Minimum number of exported types for a family to qualify
    /// for hierarchy inference. Default: 2.
    fn min_components_for_hierarchy(&self) -> usize {
        2
    }

    /// Compute component hierarchy deterministically from three signals:
    ///
    /// 1. **Prop absorption** (`structural_changes`): If a parent component
    ///    had props removed, and those props now exist on a new family member,
    ///    that member is a consumer child of the parent.
    ///
    /// 2. **Cross-family extends mapping** (`rendered_components` + `extends`):
    ///    If component A renders component X from a different family, and
    ///    component B's props interface extends X's props interface, then B
    ///    is a consumer child of A (B wraps X, which is rendered by A).
    ///
    /// 3. **Internal rendering** (`rendered_components`): If a parent renders
    ///    a child internally, that child is prop-passed, not a direct JSX child.
    ///
    /// The method works on the NEW surface and structural changes. It returns
    /// the expected hierarchy for the new version.
    fn compute_deterministic_hierarchy(
        &self,
        new_surface: &ApiSurface,
        structural_changes: &[StructuralChange],
    ) -> HashMap<String, HashMap<String, Vec<ExpectedChild>>> {
        use std::collections::{BTreeMap, HashSet};

        // ── Index: group hierarchy candidates by family ──────────────

        let mut families: HashMap<String, Vec<&Symbol>> = HashMap::new();
        for sym in &new_surface.symbols {
            if !self.is_hierarchy_candidate(sym) {
                continue;
            }
            if let Some(family) = self.family_name_from_symbols(&[sym]) {
                families.entry(family).or_default().push(sym);
            }
        }

        // ── Index: interface extends map ─────────────────────────────
        //
        // Maps interface name → what it extends.
        // e.g., "DropdownProps" → "MenuProps"
        // Also maps "DropdownListProps" → "MenuListProps".
        let mut iface_extends: HashMap<&str, &str> = HashMap::new();
        for sym in &new_surface.symbols {
            if sym.kind == SymbolKind::Interface {
                if let Some(ext) = &sym.extends {
                    iface_extends.insert(&sym.name, ext.as_str());
                }
            }
        }

        // ── Index: component → props interface name ──────────────────
        //
        // Convention: component "Dropdown" → props interface "DropdownProps".
        // We verify the interface actually exists in the surface.
        let iface_names: HashSet<&str> = new_surface
            .symbols
            .iter()
            .filter(|s| s.kind == SymbolKind::Interface)
            .map(|s| s.name.as_str())
            .collect();

        // ── Index: props interface → component name ──────────────────
        //
        // Reverse mapping: "MenuProps" → "Menu", "MenuListProps" → "MenuList"
        // Used for cross-family extends resolution.
        let mut props_to_component: HashMap<String, &str> = HashMap::new();
        for sym in &new_surface.symbols {
            if !self.is_hierarchy_candidate(sym) {
                continue;
            }
            let props_name = format!("{}Props", sym.name);
            if iface_names.contains(props_name.as_str()) {
                props_to_component.insert(props_name, &sym.name);
            }
        }

        // ── Signal 1: Prop absorption ────────────────────────────────
        //
        // For each parent interface with removed members, find new family
        // members whose props interface has matching member names.
        // parent_component → set of child component names that absorbed props.

        // First, collect removed members per parent symbol.
        // StructuralChange for "Modal.title" means member "title" removed
        // from the symbol named "Modal" (or its Props interface).
        let mut removed_props_by_parent: HashMap<String, HashSet<String>> = HashMap::new();
        for change in structural_changes {
            if let StructuralChangeType::Removed(ChangeSubject::Member { name, .. }) =
                &change.change_type
            {
                // The parent is the symbol that lost the member.
                // change.symbol is "Modal.title" → parent is "Modal"
                // OR change.symbol is "ModalProps" and member is "title"
                let parent = if let Some((p, _)) = change.symbol.rsplit_once('.') {
                    // "ModalProps.title" → "ModalProps" → strip "Props" → "Modal"
                    p.strip_suffix("Props").unwrap_or(p).to_string()
                } else {
                    // Just "ModalProps" → strip "Props" → "Modal"
                    change
                        .symbol
                        .strip_suffix("Props")
                        .unwrap_or(&change.symbol)
                        .to_string()
                };
                removed_props_by_parent
                    .entry(parent)
                    .or_default()
                    .insert(name.clone());
            }
        }

        // For each family, check which new members absorbed removed props.
        let mut absorption_children: HashMap<String, BTreeMap<String, Vec<String>>> =
            HashMap::new();

        for members in families.values() {
            for parent in members.iter() {
                let removed = match removed_props_by_parent.get(&parent.name) {
                    Some(r) if !r.is_empty() => r,
                    _ => continue,
                };

                // Find family members that have matching prop names
                for candidate in members.iter() {
                    if candidate.name == parent.name {
                        continue;
                    }

                    // Check candidate's own members
                    let candidate_props: HashSet<&str> =
                        candidate.members.iter().map(|m| m.name.as_str()).collect();

                    // Also check the candidate's Props interface members
                    let props_iface_name = format!("{}Props", candidate.name);
                    let iface_props: HashSet<&str> = new_surface
                        .symbols
                        .iter()
                        .find(|s| s.name == props_iface_name && s.kind == SymbolKind::Interface)
                        .map(|s| s.members.iter().map(|m| m.name.as_str()).collect())
                        .unwrap_or_default();

                    let all_candidate_props: HashSet<&str> =
                        candidate_props.union(&iface_props).copied().collect();

                    let absorbed: Vec<String> = removed
                        .iter()
                        .filter(|prop| all_candidate_props.contains(prop.as_str()))
                        .cloned()
                        .collect();

                    if !absorbed.is_empty() {
                        absorption_children
                            .entry(parent.name.clone())
                            .or_default()
                            .insert(candidate.name.clone(), absorbed);
                    }
                }
            }
        }

        // ── Signal 2: Cross-family extends mapping ───────────────────
        //
        // If Dropdown renders Menu (from Menu family), and DropdownList's
        // props extend MenuListProps → DropdownList maps to MenuList.
        // If Menu's hierarchy says "Menu renders MenuList internally" (or
        // MenuList is a known child of Menu), then DropdownList is a child
        // of Dropdown by the same relationship.
        //
        // Build: for each family, a map of
        //   component → {rendered_external_component → external_component's family component}
        // Then: for each family member whose props extend an external component's
        // props, it maps to that external component.

        // extends_map: family member → external component it wraps
        // e.g., "Dropdown" → "Menu", "DropdownList" → "MenuList"
        let mut extends_map: HashMap<&str, &str> = HashMap::new();
        for members in families.values() {
            for sym in members {
                let props_name = format!("{}Props", sym.name);
                if let Some(ext_iface) = iface_extends.get(props_name.as_str()) {
                    // Strip Omit<...> wrapper if present — just get the base name
                    let ext_clean = ext_iface
                        .strip_prefix("Omit<")
                        .and_then(|s| s.split(',').next())
                        .unwrap_or(ext_iface);
                    if let Some(ext_component) = props_to_component.get(ext_clean) {
                        // Only cross-family: the extended component should NOT be
                        // in the same family.
                        let ext_family = self.family_name_from_symbols(&[
                            // Find the actual Symbol for the extended component
                            new_surface
                                .symbols
                                .iter()
                                .find(|s| s.name.as_str() == *ext_component)
                                .unwrap_or(sym),
                        ]);
                        let own_family = self.family_name_from_symbols(&[sym]);
                        if ext_family != own_family {
                            extends_map.insert(&sym.name, ext_component);
                        }
                    }
                }
            }
        }

        // ── Combine signals into hierarchy ───────────────────────────

        let mut result: HashMap<String, HashMap<String, Vec<ExpectedChild>>> = HashMap::new();

        for (family_name, members) in &families {
            let member_names: HashSet<&str> = members.iter().map(|s| s.name.as_str()).collect();
            let mut family_hierarchy: HashMap<String, Vec<ExpectedChild>> = HashMap::new();

            // What each component renders from the family (Signal 3: internal rendering)
            let mut renders_family: HashMap<&str, HashSet<&str>> = HashMap::new();
            for sym in members {
                let family_renders: HashSet<&str> = sym
                    .rendered_components
                    .iter()
                    .filter(|r| {
                        member_names.contains(r.as_str()) && r.as_str() != sym.name.as_str()
                    })
                    .map(|r| r.as_str())
                    .collect();
                if !family_renders.is_empty() {
                    renders_family.insert(&sym.name, family_renders);
                }
            }

            for parent in members.iter() {
                let mut children: BTreeMap<&str, ExpectedChild> = BTreeMap::new();

                // ── Signal 1: absorption ─────────────────────────────
                // Children that absorbed removed props from this parent.
                if let Some(absorbed) = absorption_children.get(&parent.name) {
                    for child_name in absorbed.keys() {
                        if !member_names.contains(child_name.as_str()) {
                            continue;
                        }
                        // Is this child rendered internally by parent? → prop-passed
                        let parent_renders = renders_family.get(parent.name.as_str());
                        let is_rendered = parent_renders
                            .map(|r| r.contains(child_name.as_str()))
                            .unwrap_or(false);

                        let child = if is_rendered {
                            // Parent renders it internally → prop-passed
                            ExpectedChild {
                                name: child_name.clone(),
                                required: false,
                                mechanism: "prop".to_string(),
                                prop_name: None,
                            }
                        } else {
                            // Parent does NOT render it → direct JSX child
                            ExpectedChild::new(child_name, false)
                        };
                        children.insert(child_name.as_str(), child);
                    }
                }

                // ── Signal 2: cross-family extends mapping ───────────
                // If this parent extends an external component (e.g.,
                // Dropdown extends Menu) AND renders it internally, find
                // which family members extend the external component's
                // children.
                //
                // The "renders it internally" check prevents inverse
                // mappings: DropdownList extends MenuList but does NOT
                // render Menu, so it should not map Menu's children as
                // its own children.
                if let Some(ext_parent) = extends_map.get(parent.name.as_str()) {
                    // Gate: parent must render the external parent in its JSX,
                    // AND the external parent must be a container (renders
                    // family members). Without the container check, leaf
                    // wrappers like DropdownList (renders MenuList) would
                    // incorrectly claim siblings as children.
                    let renders_ext_parent = parent
                        .rendered_components
                        .iter()
                        .any(|r| r.as_str() == *ext_parent);

                    // Check that ext_parent is a container in its family:
                    // it must render at least one component from the same
                    // external family.
                    let ext_parent_sym = new_surface
                        .symbols
                        .iter()
                        .find(|s| s.name.as_str() == *ext_parent);
                    let ext_parent_is_container = ext_parent_sym
                        .map(|ep| {
                            let ep_family = self.family_name_from_symbols(&[ep]);
                            ep.rendered_components.iter().any(|rc| {
                                new_surface
                                    .symbols
                                    .iter()
                                    .filter(|s| self.is_hierarchy_candidate(s))
                                    .any(|s| {
                                        s.name.as_str() == rc.as_str()
                                            && self.family_name_from_symbols(&[s]) == ep_family
                                    })
                            })
                        })
                        .unwrap_or(false);

                    if !renders_ext_parent || !ext_parent_is_container {
                        // Skip: either this component doesn't render the
                        // external parent, or the external parent isn't a
                        // container in its family.
                    } else if let Some(ext_sym) = ext_parent_sym {
                        // For each family member that extends an external component,
                        // check if that external component is rendered by the
                        // external parent OR is a known child of it.
                        for candidate in members.iter() {
                            if candidate.name == parent.name {
                                continue;
                            }
                            if children.contains_key(candidate.name.as_str()) {
                                continue; // Already found via absorption
                            }

                            // Does this candidate extend something from the external family?
                            if let Some(ext_child) = extends_map.get(candidate.name.as_str()) {
                                // ext_child is the external component this candidate wraps.
                                // Is it rendered by the external parent?
                                let ext_renders_child =
                                    ext_sym.rendered_components.contains(&ext_child.to_string());

                                // Only add the child if:
                                // 1. The external parent does NOT render it
                                //    (consumers must provide it as JSX child)
                                // 2. The candidate's external component is
                                //    NOT itself a container (to prevent
                                //    mapping parents as children — e.g.,
                                //    Menu should not be a child of MenuGroup)
                                if !ext_renders_child {
                                    // Check: is ext_child a container?
                                    let ext_child_sym = new_surface
                                        .symbols
                                        .iter()
                                        .find(|s| s.name.as_str() == *ext_child);
                                    let ext_child_is_container = ext_child_sym
                                        .map(|ec| {
                                            let ec_family = self.family_name_from_symbols(&[ec]);
                                            ec.rendered_components.iter().any(|rc| {
                                                new_surface
                                                    .symbols
                                                    .iter()
                                                    .filter(|s| self.is_hierarchy_candidate(s))
                                                    .any(|s| {
                                                        s.name.as_str() == rc.as_str()
                                                            && self.family_name_from_symbols(&[s])
                                                                == ec_family
                                                    })
                                            })
                                        })
                                        .unwrap_or(false);

                                    if !ext_child_is_container {
                                        children.insert(
                                            &candidate.name,
                                            ExpectedChild::new(&candidate.name, false),
                                        );
                                    }
                                }
                            }
                        }
                    } // else renders_ext_parent
                }

                if !children.is_empty() {
                    family_hierarchy.insert(parent.name.clone(), children.into_values().collect());
                }
            }

            if !family_hierarchy.is_empty() {
                result.insert(family_name.clone(), family_hierarchy);
            }
        }

        result
    }
}

/// Deterministic data preparation for LLM-based rename inference.
///
/// Languages that benefit from LLM-detected rename patterns (e.g., CSS
/// physical→logical property renames, interface rename mappings) implement
/// this to prepare the data for the LLM call.
///
/// The orchestrator calls these methods to build LLM inputs. The LLM call
/// itself and prompt construction stay in the orchestrator/LLM crate.
pub trait RenameSemantics {
    /// Sample removed constants for rename pattern inference.
    ///
    /// Default implementation returns the first 30. Language impls can
    /// prioritize certain suffixes/patterns for better LLM pattern discovery.
    fn sample_removed_constants<'a>(
        &self,
        removed: &[&'a str],
        _added: &[&'a str],
    ) -> Vec<&'a str> {
        removed.iter().take(30).copied().collect()
    }

    /// Sample added constants for rename pattern inference.
    ///
    /// Default implementation returns the first 30.
    fn sample_added_constants<'a>(&self, _removed: &[&'a str], added: &[&'a str]) -> Vec<&'a str> {
        added.iter().take(30).copied().collect()
    }

    /// Minimum count of removed constants to trigger rename inference.
    /// Default: 50.
    fn min_removed_for_constant_inference(&self) -> usize {
        50
    }

    /// Minimum count of removed interfaces to trigger interface rename
    /// inference. Default: 2.
    fn min_removed_for_interface_inference(&self) -> usize {
        2
    }
}

/// Deterministic body-level analysis for behavioral change detection.
///
/// Languages with framework-specific body patterns (e.g., JSX diff and CSS
/// variable scanning for TypeScript/React) implement this to detect
/// behavioral breaks from function body changes without LLM assistance.
///
/// The orchestrator calls `analyze_changed_body` during BU Phase 1 for each
/// changed function that passes visibility filtering.
///
/// The `category_label` field on results uses the serde serialization format
/// of the language's `Category` type. At the call site, the orchestrator
/// deserializes this into `L::Category` via serde.
pub trait BodyAnalysisSemantics {
    /// Run deterministic analysis on a changed function's body.
    ///
    /// Returns a list of (description, category_label) pairs representing
    /// behavioral breaks detected. The category_label is the string form
    /// of the language's Category enum (e.g., "dom_structure" for
    /// `TsCategory::DomStructure`).
    ///
    /// TypeScript: runs JSX diff + CSS variable scanning.
    /// Other languages: may check annotation changes, decorator changes, etc.
    fn analyze_changed_body(
        &self,
        old_body: &str,
        new_body: &str,
        func_name: &str,
        file_path: &str,
    ) -> Vec<BodyAnalysisResult>;
}

/// Language-specific human-readable descriptions for changes.
///
/// Each language owns its messaging entirely -- there is no generic
/// template in core. These descriptions are consumed by LLMs downstream,
/// so language-appropriate terminology matters.
pub trait MessageFormatter {
    /// Produce a human-readable description for a structural change.
    fn describe(&self, change: &StructuralChange) -> String;
}

/// The core language abstraction.
///
/// Composes `LanguageSemantics + MessageFormatter` and adds four associated
/// types representing language-specific data flowing through the pipeline.
///
/// Code that only needs semantic rules can take `&dyn LanguageSemantics`
/// (no generic parameter). Code that needs the associated types takes
/// `L: Language`.
pub trait Language: LanguageSemantics + MessageFormatter + Send + Sync + 'static {
    /// Behavioral change categories for this language.
    type Category: Debug + Clone + Serialize + DeserializeOwned + Eq + std::hash::Hash + Send + Sync;

    /// Manifest change types for this language's package system.
    type ManifestChangeType: Debug
        + Clone
        + Serialize
        + DeserializeOwned
        + Eq
        + PartialEq
        + Send
        + Sync;

    /// Evidence data carried on behavioral changes.
    type Evidence: Debug + Clone + Serialize + DeserializeOwned + Send + Sync;

    /// Language-specific report data.
    type ReportData: Debug + Clone + Serialize + DeserializeOwned + Send + Sync;

    // ── Constants ────────────────────────────────────────────────────

    /// Symbol kinds that represent type definitions eligible for rename inference.
    /// TypeScript: `&[SymbolKind::Interface, SymbolKind::Class]`
    /// Go: `&[SymbolKind::Struct, SymbolKind::Interface]`
    const RENAMEABLE_SYMBOL_KINDS: &'static [SymbolKind];

    /// Language identifier for serialization dispatch.
    const NAME: &'static str;

    /// Manifest file path(s) for this language's package system.
    ///
    /// TypeScript: `&["package.json"]`
    /// Go: `&["go.mod"]`
    /// Java: `&["pom.xml"]` or `&["build.gradle"]`
    ///
    /// TODO: Reconsider — the orchestrator currently reads these files via git
    /// and passes content to `diff_manifest_content`. A future refactor should
    /// unify all git plumbing in the orchestrator so language impls are pure
    /// content processors.
    const MANIFEST_FILES: &'static [&'static str];

    /// Source file glob patterns for `git diff --name-only` filtering.
    ///
    /// TypeScript: `&["*.ts", "*.tsx"]`
    /// Go: `&["*.go"]`
    /// Java: `&["*.java"]`
    ///
    /// TODO: Same reconsideration as MANIFEST_FILES.
    const SOURCE_FILE_PATTERNS: &'static [&'static str];

    // ── Analysis pipeline methods ───────────────────────────────────

    /// Extract the public API surface from source code at a git ref.
    ///
    /// The implementation is responsible for checking out the ref,
    /// running any required build steps, parsing the output, and
    /// cleaning up temporary files.
    fn extract(&self, repo: &Path, git_ref: &str) -> Result<ApiSurface>;

    /// Parse the diff between two git refs and identify all functions
    /// whose bodies changed (public AND private).
    fn parse_changed_functions(
        &self,
        repo: &Path,
        from_ref: &str,
        to_ref: &str,
    ) -> Result<Vec<ChangedFunction>>;

    /// Given a function, find what calls it (callers, not callees).
    fn find_callers(&self, file: &Path, symbol_name: &str) -> Result<Vec<Caller>>;

    /// Given a public symbol, find all references to it across the project.
    fn find_references(&self, file: &Path, symbol_name: &str) -> Result<Vec<Reference>>;

    /// Given a source file, find its associated test file(s) by convention.
    fn find_tests(&self, repo: &Path, source_file: &Path) -> Result<Vec<TestFile>>;

    /// Diff the test file between two refs. Returns changed assertion lines.
    fn diff_test_assertions(
        &self,
        repo: &Path,
        test_file: &TestFile,
        from_ref: &str,
        to_ref: &str,
    ) -> Result<TestDiff>;

    // ── Methods ─────────────────────────────────────────────────────

    /// Diff manifest content between two versions.
    ///
    /// The orchestrator reads the manifest file(s) at both refs and passes
    /// the raw content here. The language interprets the format and determines
    /// what changed and whether it's breaking.
    ///
    /// TODO: Reconsider — same as above re: git plumbing ownership.
    fn diff_manifest_content(old: &str, new: &str) -> Vec<crate::types::ManifestChange<Self>>
    where
        Self: Sized;

    /// Whether a file path should be excluded from BU analysis.
    ///
    /// Filters out test files, build artifacts, index/barrel files, etc.
    /// TypeScript: excludes `index.ts`, `.d.ts`, `.test.`, `.spec.`,
    /// `__tests__/`, `dist/`
    ///
    /// TODO: Same reconsideration as above.
    fn should_exclude_from_analysis(path: &Path) -> bool;

    /// Build the language-specific report from analysis results.
    ///
    /// This is the primary report-building entry point. The Language owns
    /// the entire report construction — language-agnostic structure (grouping
    /// changes by file, counting breaks) AND language-specific enrichment
    /// (component detection, hierarchy, child components, etc.).
    ///
    /// The result is dropped into a `ReportEnvelope` by the caller.
    fn build_report(
        results: &crate::types::AnalysisResult<Self>,
        repo: &Path,
        from_ref: &str,
        to_ref: &str,
    ) -> crate::types::AnalysisReport<Self>
    where
        Self: Sized;

    // ── Behavioral change methods ───────────────────────────────

    /// Determine the behavioral change kind from the evidence type.
    /// TypeScript: LLM/body analysis → Class (component-level), test delta → Function
    /// Default: always Function
    fn behavioral_change_kind(&self, _evidence_type: &EvidenceType) -> BehavioralChangeKind {
        BehavioralChangeKind::Function
    }

    /// Extract symbol references from a behavioral change description.
    /// TypeScript: extracts PascalCase component names (e.g., `<Modal>`, `` `Button` ``)
    /// Default: empty vec
    fn extract_referenced_symbols(&self, _description: &str) -> Vec<String> {
        vec![]
    }

    /// Format a qualified name for display in reports.
    /// TypeScript: `src/Modal.tsx::Modal` → `Modal`
    /// Default: return the qualified name as-is
    fn display_name(&self, qualified_name: &str) -> String {
        qualified_name.to_string()
    }

    // ── v2 SD (Source-Level Diff) pipeline ───────────────────────────

    /// Run the SD pipeline for this language.
    ///
    /// Reads component source files at both refs, extracts structured
    /// profiles, diffs them, and optionally builds composition trees.
    ///
    /// Default implementation returns empty results (SD not supported).
    /// TypeScript overrides this to provide full SD analysis.
    fn run_source_diff(
        &self,
        _repo: &Path,
        _from_ref: &str,
        _to_ref: &str,
        _dep_css_dir: Option<&Path>,
    ) -> Result<SdPipelineResult> {
        Ok(SdPipelineResult::default())
    }
}

// ── Convenience functions (TD) ──────────────────────────────────────────

/// Compare two API surfaces using language-specific semantic rules.
///
/// This is the primary entry point for the TD (Top-Down) pipeline.
/// The `semantics` parameter provides language-specific rules.
pub fn diff_surfaces_with_semantics(
    old: &ApiSurface,
    new: &ApiSurface,
    semantics: &dyn LanguageSemantics,
) -> Vec<StructuralChange> {
    crate::diff::diff_surfaces_with_semantics(old, new, semantics)
}

/// Compare two API surfaces using minimal semantics (no language-specific rules).
///
/// This uses `MinimalSemantics` which is language-agnostic: no member additions
/// are breaking, no union parsing, no post-processing. For language-aware
/// diffing, use `diff_surfaces_with_semantics` with a `LanguageSemantics` impl.
pub fn diff_surfaces(old: &ApiSurface, new: &ApiSurface) -> Vec<StructuralChange> {
    crate::diff::diff_surfaces(old, new)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{SymbolKind, Visibility};
    use std::path::PathBuf;

    /// Minimal HierarchySemantics impl for testing.
    struct TestHierarchy;

    impl HierarchySemantics for TestHierarchy {
        fn family_source_paths(
            &self,
            _repo: &Path,
            _git_ref: &str,
            _family_name: &str,
        ) -> Vec<String> {
            Vec::new()
        }

        fn family_name_from_symbols(&self, symbols: &[&Symbol]) -> Option<String> {
            symbols.first().and_then(|s| {
                s.file
                    .parent()
                    .and_then(|p| p.file_name())
                    .map(|n| n.to_string_lossy().to_string())
            })
        }

        fn cross_family_relationships(
            &self,
            _repo: &Path,
            _git_ref: &str,
        ) -> Vec<(String, String, String)> {
            Vec::new()
        }

        fn related_family_content(
            &self,
            _repo: &Path,
            _git_ref: &str,
            _family_name: &str,
            _relationship_names: &[String],
        ) -> Option<String> {
            None
        }

        fn is_hierarchy_candidate(&self, sym: &Symbol) -> bool {
            matches!(
                sym.kind,
                SymbolKind::Variable | SymbolKind::Function | SymbolKind::Constant
            ) && sym.name.starts_with(|c: char| c.is_ascii_uppercase())
        }
    }

    // ─── Test helpers ────────────────────────────────────────────────

    fn make_component(name: &str, family: &str, rendered: Vec<&str>) -> Symbol {
        let mut sym = Symbol::new(
            name,
            format!("src/components/{}/{}.{}", family, name, name),
            SymbolKind::Variable,
            Visibility::Exported,
            PathBuf::from(format!("src/components/{}/{}.d.ts", family, name)),
            1,
        );
        sym.rendered_components = rendered.into_iter().map(|s| s.to_string()).collect();
        sym
    }

    fn make_interface(
        name: &str,
        family: &str,
        extends: Option<&str>,
        members: Vec<&str>,
    ) -> Symbol {
        let mut sym = Symbol::new(
            name,
            format!("src/components/{}/{}.{}", family, name, name),
            SymbolKind::Interface,
            Visibility::Exported,
            PathBuf::from(format!("src/components/{}/{}.d.ts", family, name)),
            1,
        );
        sym.extends = extends.map(|e| e.to_string());
        sym.members = members
            .into_iter()
            .map(|m| {
                Symbol::new(
                    m,
                    format!("{}.{}", name, m),
                    SymbolKind::Variable,
                    Visibility::Exported,
                    PathBuf::from(format!("src/components/{}/{}.d.ts", family, name)),
                    1,
                )
            })
            .collect();
        sym
    }

    /// Create a structural change for a removed member.
    fn removed_member(parent: &str, member: &str) -> StructuralChange {
        StructuralChange {
            symbol: format!("{}.{}", parent, member),
            qualified_name: format!("src/components/X/{}.{}", parent, member),
            kind: SymbolKind::Interface,
            package: None,
            change_type: StructuralChangeType::Removed(ChangeSubject::Member {
                name: member.to_string(),
                kind: SymbolKind::Variable,
            }),
            before: None,
            after: None,
            description: format!("property `{}` was removed", member),
            is_breaking: true,
            impact: None,
            migration_target: None,
        }
    }

    fn child_names(
        result: &HashMap<String, HashMap<String, Vec<ExpectedChild>>>,
        family: &str,
        component: &str,
    ) -> Vec<String> {
        result
            .get(family)
            .and_then(|f| f.get(component))
            .map(|children| children.iter().map(|c| c.name.clone()).collect())
            .unwrap_or_default()
    }

    fn child_mechanism(
        result: &HashMap<String, HashMap<String, Vec<ExpectedChild>>>,
        family: &str,
        parent: &str,
        child: &str,
    ) -> Option<String> {
        result
            .get(family)
            .and_then(|f| f.get(parent))
            .and_then(|children| children.iter().find(|c| c.name == child))
            .map(|c| c.mechanism.clone())
    }

    fn has_entry(
        result: &HashMap<String, HashMap<String, Vec<ExpectedChild>>>,
        family: &str,
        component: &str,
    ) -> bool {
        result
            .get(family)
            .map(|f| f.contains_key(component))
            .unwrap_or(false)
    }

    // ═══════════════════════════════════════════════════════════════════
    // Signal 1: Prop absorption (Modal v5→v6 pattern)
    // ═══════════════════════════════════════════════════════════════════
    //
    // Modal had props (title, description, actions, footer, bodyAriaRole, etc.)
    // that were removed. These props now exist on new child components:
    //   - ModalHeader absorbed: title, description, help, titleIconVariant, titleLabel
    //   - ModalBody absorbed: bodyAriaRole (as "role")
    //   - ModalFooter absorbed: actions, footer
    //
    // Since Modal does NOT render ModalHeader/ModalBody/ModalFooter
    // internally (they're not in rendered_components), they are direct
    // JSX children (mechanism = "child").

    #[test]
    fn signal1_modal_v6_absorption() {
        let h = TestHierarchy;

        // New surface: v6 Modal family
        let new_surface = ApiSurface {
            symbols: vec![
                make_component("Modal", "Modal", vec!["ModalContent"]),
                make_component(
                    "ModalHeader",
                    "Modal",
                    vec!["ModalBoxDescription", "ModalBoxTitle"],
                ),
                make_component("ModalBody", "Modal", vec![]),
                make_component("ModalFooter", "Modal", vec![]),
                // ModalProps interface with no extends (standalone)
                make_interface(
                    "ModalProps",
                    "Modal",
                    None,
                    vec!["children", "className", "isOpen", "onClose", "variant"],
                ),
                // ModalHeaderProps has the absorbed props
                make_interface(
                    "ModalHeaderProps",
                    "Modal",
                    None,
                    vec![
                        "children",
                        "className",
                        "title",
                        "description",
                        "help",
                        "titleIconVariant",
                        "titleScreenReaderText",
                    ],
                ),
                // ModalBodyProps has bodyAriaRole as "role"
                make_interface(
                    "ModalBodyProps",
                    "Modal",
                    None,
                    vec!["children", "className", "role"],
                ),
                // ModalFooterProps has the absorbed actions
                make_interface(
                    "ModalFooterProps",
                    "Modal",
                    None,
                    vec!["children", "className"],
                ),
            ],
        };

        // Structural changes: Modal had these props removed
        let changes = vec![
            removed_member("ModalProps", "title"),
            removed_member("ModalProps", "description"),
            removed_member("ModalProps", "help"),
            removed_member("ModalProps", "titleIconVariant"),
            removed_member("ModalProps", "titleLabel"),
            removed_member("ModalProps", "bodyAriaRole"),
            removed_member("ModalProps", "actions"),
            removed_member("ModalProps", "footer"),
            removed_member("ModalProps", "header"),
            removed_member("ModalProps", "showClose"),
            removed_member("ModalProps", "hasNoBodyWrapper"),
        ];

        let result = h.compute_deterministic_hierarchy(&new_surface, &changes);

        // Modal should have consumer children from absorption
        assert!(
            has_entry(&result, "Modal", "Modal"),
            "Modal should be a parent"
        );
        let modal_children = child_names(&result, "Modal", "Modal");

        // ModalHeader absorbed title, description, help, titleIconVariant
        assert!(
            modal_children.contains(&"ModalHeader".to_string()),
            "ModalHeader absorbed props from Modal"
        );

        // ModalBody absorbed bodyAriaRole (as "role" — but "bodyAriaRole" is in removed set
        // and doesn't match "role" on ModalBody. However "actions" maps to ModalFooter
        // since ModalFooterProps doesn't have "actions" either... Let's check what DOES match)

        // ModalFooter: its props are [children, className] — "actions" and "footer"
        // are NOT in ModalFooterProps in our test. So ModalFooter won't be detected
        // via absorption unless "children" matches a removed prop name.
        // In the real pipeline, "children" is a removed prop name? No. "footer" is.
        // "footer" is not in ModalFooterProps members. So this test correctly shows
        // that absorption only works when the prop names actually match.

        // ModalHeader has "title", "description", "help", "titleIconVariant" which
        // match removed props from ModalProps → detected as child
        assert!(
            modal_children.contains(&"ModalHeader".to_string()),
            "ModalHeader absorbed title/description/help/titleIconVariant from Modal"
        );

        // Since Modal doesn't render ModalHeader internally → mechanism = "child"
        assert_eq!(
            child_mechanism(&result, "Modal", "Modal", "ModalHeader"),
            Some("child".to_string()),
            "ModalHeader is a direct JSX child (not rendered internally)"
        );
    }

    // ═══════════════════════════════════════════════════════════════════
    // Signal 2: Cross-family extends (Dropdown wraps Menu)
    // ═══════════════════════════════════════════════════════════════════
    //
    // Dropdown.tsx renders Menu and MenuContent (from Menu family).
    // DropdownProps extends MenuProps.
    // DropdownListProps extends MenuListProps.
    // DropdownItemProps extends MenuItemProps.
    // DropdownGroupProps extends MenuGroupProps.
    //
    // Menu renders MenuContent internally. MenuContent is NOT rendered
    // by Dropdown's family members as a consumer child, but Menu's
    // relationship to MenuList/MenuGroup tells us:
    //   - Menu does NOT render MenuList/MenuGroup internally → they are
    //     consumer children of Menu
    //   - By extends mapping: DropdownList (extends MenuList) is a consumer
    //     child of Dropdown (extends Menu)

    #[test]
    fn signal2_dropdown_cross_family_extends() {
        let h = TestHierarchy;

        let new_surface = ApiSurface {
            symbols: vec![
                // Dropdown family components
                make_component(
                    "Dropdown",
                    "Dropdown",
                    vec!["Menu", "MenuContent", "Popper"],
                ),
                make_component("DropdownGroup", "Dropdown", vec!["MenuGroup"]),
                make_component("DropdownItem", "Dropdown", vec!["MenuItem"]),
                make_component("DropdownList", "Dropdown", vec!["MenuList"]),
                // Dropdown family interfaces with extends
                make_interface(
                    "DropdownProps",
                    "Dropdown",
                    Some("MenuProps"),
                    vec!["children", "className", "toggle", "isOpen", "onSelect"],
                ),
                make_interface(
                    "DropdownGroupProps",
                    "Dropdown",
                    Some("MenuGroupProps"),
                    vec!["children", "label"],
                ),
                make_interface(
                    "DropdownItemProps",
                    "Dropdown",
                    Some("MenuItemProps"),
                    vec!["children", "value", "isDisabled"],
                ),
                make_interface(
                    "DropdownListProps",
                    "Dropdown",
                    Some("MenuListProps"),
                    vec!["children"],
                ),
                // Menu family components (different directory = different family)
                // Menu renders MenuContext (a React context provider) internally.
                // This makes Menu a "container" in its family — it renders at
                // least one family member.
                make_component("Menu", "Menu", vec!["MenuContext"]),
                make_component("MenuContext", "Menu", vec![]),
                make_component("MenuContent", "Menu", vec![]),
                make_component("MenuList", "Menu", vec![]),
                make_component("MenuItem", "Menu", vec![]),
                make_component("MenuGroup", "Menu", vec!["MenuList"]),
                // Menu family interfaces
                make_interface(
                    "MenuProps",
                    "Menu",
                    None,
                    vec!["children", "className", "onSelect"],
                ),
                make_interface("MenuListProps", "Menu", None, vec!["children"]),
                make_interface("MenuItemProps", "Menu", None, vec!["children", "value"]),
                make_interface("MenuGroupProps", "Menu", None, vec!["children", "label"]),
            ],
        };

        let result = h.compute_deterministic_hierarchy(&new_surface, &[]);

        // Dropdown extends Menu. Menu does NOT render MenuList, MenuItem, MenuGroup
        // internally → they are consumer children of Menu.
        // By extends mapping:
        //   DropdownList (extends MenuList) → consumer child of Dropdown
        //   DropdownItem (extends MenuItem) → consumer child of Dropdown
        //   DropdownGroup (extends MenuGroup) → consumer child of Dropdown
        assert!(
            has_entry(&result, "Dropdown", "Dropdown"),
            "Dropdown should be a parent via extends mapping"
        );

        let dropdown_children = child_names(&result, "Dropdown", "Dropdown");

        // Dropdown should contain children whose external components are
        // NOT containers. MenuList and MenuItem are leaves (render nothing
        // from Menu family), so DropdownList and DropdownItem map as children.
        // MenuGroup IS a container (renders MenuList), so DropdownGroup is
        // NOT a direct child of Dropdown — it has its own hierarchy.
        assert!(
            dropdown_children.contains(&"DropdownItem".to_string()),
            "DropdownItem wraps MenuItem (leaf) → consumer child of Dropdown"
        );
        assert!(
            dropdown_children.contains(&"DropdownList".to_string()),
            "DropdownList wraps MenuList (leaf) → consumer child of Dropdown"
        );
        assert!(
            !dropdown_children.contains(&"DropdownGroup".to_string()),
            "DropdownGroup wraps MenuGroup (container) → not a direct child of Dropdown"
        );

        // DropdownGroup IS a parent (MenuGroup renders MenuList, is a container).
        // DropdownGroup should find DropdownItem as a child (MenuItem is a leaf,
        // not rendered by MenuGroup).
        assert!(
            has_entry(&result, "Dropdown", "DropdownGroup"),
            "DropdownGroup should be a parent"
        );
        let group_children = child_names(&result, "Dropdown", "DropdownGroup");
        assert!(
            group_children.contains(&"DropdownItem".to_string()),
            "DropdownItem is a child of DropdownGroup"
        );
        // DropdownGroup should NOT list Dropdown or DropdownList as children
        assert!(
            !group_children.contains(&"Dropdown".to_string()),
            "Dropdown is a container, not a child of DropdownGroup"
        );

        // DropdownList and DropdownItem should NOT be parents
        assert!(
            !has_entry(&result, "Dropdown", "DropdownList"),
            "DropdownList should NOT be a parent (MenuList is not a container)"
        );
        assert!(
            !has_entry(&result, "Dropdown", "DropdownItem"),
            "DropdownItem should NOT be a parent"
        );
    }

    // ═══════════════════════════════════════════════════════════════════
    // Signal 3: Internal rendering determines prop-passed mechanism
    // ═══════════════════════════════════════════════════════════════════
    //
    // When a parent renders a child internally AND that child absorbed
    // a removed prop, the mechanism should be "prop" (not "child").

    #[test]
    fn signal3_rendered_internally_means_prop_passed() {
        let h = TestHierarchy;

        // FormFieldGroup renders FormFieldGroupHeader internally (via header prop)
        let new_surface = ApiSurface {
            symbols: vec![
                make_component(
                    "FormFieldGroup",
                    "Form",
                    vec!["FormFieldGroupHeader"], // renders it internally!
                ),
                make_component("FormFieldGroupHeader", "Form", vec![]),
                make_component("FormGroup", "Form", vec![]),
                // Interfaces
                make_interface(
                    "FormFieldGroupProps",
                    "Form",
                    None,
                    vec!["children", "header"],
                ),
                make_interface(
                    "FormFieldGroupHeaderProps",
                    "Form",
                    None,
                    vec!["titleText", "titleDescription"],
                ),
            ],
        };

        // FormFieldGroup had titleText and titleDescription removed
        let changes = vec![
            removed_member("FormFieldGroupProps", "titleText"),
            removed_member("FormFieldGroupProps", "titleDescription"),
        ];

        let result = h.compute_deterministic_hierarchy(&new_surface, &changes);

        // FormFieldGroupHeader absorbed titleText/titleDescription from FormFieldGroup
        assert!(has_entry(&result, "Form", "FormFieldGroup"));
        let children = child_names(&result, "Form", "FormFieldGroup");
        assert!(
            children.contains(&"FormFieldGroupHeader".to_string()),
            "FormFieldGroupHeader absorbed props from FormFieldGroup"
        );

        // Since FormFieldGroup RENDERS FormFieldGroupHeader internally → prop-passed
        assert_eq!(
            child_mechanism(&result, "Form", "FormFieldGroup", "FormFieldGroupHeader"),
            Some("prop".to_string()),
            "FormFieldGroupHeader is rendered internally → prop-passed"
        );
    }

    // ═══════════════════════════════════════════════════════════════════
    // Masthead: all leaves, no hierarchy
    // ═══════════════════════════════════════════════════════════════════

    #[test]
    fn masthead_all_leaves() {
        let h = TestHierarchy;

        let surface = ApiSurface {
            symbols: vec![
                make_component("Masthead", "Masthead", vec![]),
                make_component("MastheadBrand", "Masthead", vec![]),
                make_component("MastheadContent", "Masthead", vec![]),
                make_component("MastheadLogo", "Masthead", vec![]),
                make_component("MastheadMain", "Masthead", vec![]),
                make_component("MastheadToggle", "Masthead", vec![]),
            ],
        };

        let result = h.compute_deterministic_hierarchy(&surface, &[]);

        assert!(
            !result.contains_key("Masthead"),
            "Masthead: all components are leaves (div wrappers)"
        );
    }

    // ═══════════════════════════════════════════════════════════════════
    // No data → empty hierarchy
    // ═══════════════════════════════════════════════════════════════════

    #[test]
    fn no_signals_empty_hierarchy() {
        let h = TestHierarchy;

        let surface = ApiSurface {
            symbols: vec![
                make_component("Modal", "Modal", vec![]),
                make_component("ModalHeader", "Modal", vec![]),
            ],
        };

        let result = h.compute_deterministic_hierarchy(&surface, &[]);
        assert!(result.is_empty(), "no signals → no hierarchy");
    }

    // ═══════════════════════════════════════════════════════════════════
    // Interfaces/types excluded from hierarchy candidates
    // ═══════════════════════════════════════════════════════════════════

    #[test]
    fn interfaces_not_hierarchy_candidates() {
        let h = TestHierarchy;

        let surface = ApiSurface {
            symbols: vec![
                make_component("Modal", "Modal", vec![]),
                make_component("ModalBody", "Modal", vec![]),
                make_interface("ModalProps", "Modal", None, vec!["children"]),
            ],
        };

        let changes = vec![removed_member("ModalProps", "title")];
        let result = h.compute_deterministic_hierarchy(&surface, &changes);

        // ModalProps should not appear as a child anywhere
        for family in result.values() {
            for children in family.values() {
                for child in children {
                    assert_ne!(
                        child.name, "ModalProps",
                        "Interfaces should not be hierarchy candidates"
                    );
                }
            }
        }
    }
}