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