1use std::collections::HashSet;
13
14use sem_core::parser::graph::EntityGraph;
15use sem_core::parser::registry::ParserRegistry;
16
17#[derive(Debug, Clone)]
19pub struct SemanticWarning {
20 pub entity_name: String,
22 pub entity_type: String,
23 pub file_path: String,
24 pub kind: WarningKind,
26 pub related: Vec<RelatedEntity>,
28}
29
30#[derive(Debug, Clone)]
31pub enum WarningKind {
32 DependencyAlsoModified,
35 DependentAlsoModified,
38 ParseFailedAfterMerge,
40}
41
42#[derive(Debug, Clone)]
43pub struct RelatedEntity {
44 pub name: String,
45 pub entity_type: String,
46 pub file_path: String,
47}
48
49pub fn validate_merge(
55 repo_root: &std::path::Path,
56 file_paths: &[String],
57 modified_entities: &[ModifiedEntity],
58 registry: &ParserRegistry,
59) -> Vec<SemanticWarning> {
60 if modified_entities.len() < 2 {
61 return vec![];
62 }
63
64 let graph = EntityGraph::build(repo_root, file_paths, registry);
66
67 let modified_ids: HashSet<String> = modified_entities
69 .iter()
70 .filter_map(|me| {
71 graph
72 .entities
73 .values()
74 .find(|e| e.name == me.name && e.file_path == me.file_path)
75 .map(|e| e.id.clone())
76 })
77 .collect();
78
79 let mut warnings = Vec::new();
80
81 for entity_id in &modified_ids {
82 let entity = match graph.entities.get(entity_id) {
83 Some(e) => e,
84 None => continue,
85 };
86
87 let deps = graph.get_dependencies(entity_id);
89 for dep in &deps {
90 if modified_ids.contains(&dep.id) {
91 warnings.push(SemanticWarning {
92 entity_name: entity.name.clone(),
93 entity_type: entity.entity_type.clone(),
94 file_path: entity.file_path.clone(),
95 kind: WarningKind::DependencyAlsoModified,
96 related: vec![RelatedEntity {
97 name: dep.name.clone(),
98 entity_type: dep.entity_type.clone(),
99 file_path: dep.file_path.clone(),
100 }],
101 });
102 }
103 }
104
105 let dependents = graph.get_dependents(entity_id);
107 for dep in &dependents {
108 if modified_ids.contains(&dep.id) && dep.id != *entity_id {
109 let already_covered = warnings.iter().any(|w| {
111 matches!(&w.kind, WarningKind::DependencyAlsoModified)
112 && w.entity_name == dep.name
113 && w.related.iter().any(|r| r.name == entity.name)
114 });
115 if !already_covered {
116 warnings.push(SemanticWarning {
117 entity_name: entity.name.clone(),
118 entity_type: entity.entity_type.clone(),
119 file_path: entity.file_path.clone(),
120 kind: WarningKind::DependentAlsoModified,
121 related: vec![RelatedEntity {
122 name: dep.name.clone(),
123 entity_type: dep.entity_type.clone(),
124 file_path: dep.file_path.clone(),
125 }],
126 });
127 }
128 }
129 }
130 }
131
132 warnings
133}
134
135#[derive(Debug, Clone)]
137pub struct ModifiedEntity {
138 pub name: String,
139 pub file_path: String,
140}
141
142impl std::fmt::Display for SemanticWarning {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 match &self.kind {
145 WarningKind::DependencyAlsoModified => {
146 write!(
147 f,
148 "warning: {} `{}` was modified and references {} `{}` which was also modified",
149 self.entity_type,
150 self.entity_name,
151 self.related[0].entity_type,
152 self.related[0].name,
153 )
154 }
155 WarningKind::DependentAlsoModified => {
156 write!(
157 f,
158 "warning: {} `{}` was modified and is used by {} `{}` which was also modified",
159 self.entity_type,
160 self.entity_name,
161 self.related[0].entity_type,
162 self.related[0].name,
163 )
164 }
165 WarningKind::ParseFailedAfterMerge => {
166 write!(
167 f,
168 "warning: merged output for `{}` failed to parse — result may be syntactically broken",
169 self.file_path,
170 )
171 }
172 }
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use std::io::Write;
180 use tempfile::TempDir;
181
182 fn setup_test_repo() -> TempDir {
183 let dir = TempDir::new().unwrap();
184
185 let ts_content = r#"export function validateInput(input: string): boolean {
187 return input.length > 0;
188}
189
190export function processData(input: string): string {
191 if (!validateInput(input)) {
192 throw new Error("invalid");
193 }
194 return input.toUpperCase();
195}
196
197export function unrelated(): number {
198 return 42;
199}
200"#;
201 let ts_path = dir.path().join("module.ts");
202 let mut f = std::fs::File::create(&ts_path).unwrap();
203 f.write_all(ts_content.as_bytes()).unwrap();
204
205 dir
206 }
207
208 #[test]
209 fn test_no_warnings_single_entity() {
210 let dir = setup_test_repo();
211 let registry = sem_core::parser::plugins::create_default_registry();
212 let warnings = validate_merge(
213 dir.path(),
214 &["module.ts".to_string()],
215 &[ModifiedEntity {
216 name: "unrelated".to_string(),
217 file_path: "module.ts".to_string(),
218 }],
219 ®istry,
220 );
221 assert!(warnings.is_empty(), "Single entity should have no warnings");
222 }
223
224 #[test]
225 fn test_warning_when_caller_and_callee_both_modified() {
226 let dir = setup_test_repo();
227 let registry = sem_core::parser::plugins::create_default_registry();
228 let warnings = validate_merge(
229 dir.path(),
230 &["module.ts".to_string()],
231 &[
232 ModifiedEntity {
233 name: "validateInput".to_string(),
234 file_path: "module.ts".to_string(),
235 },
236 ModifiedEntity {
237 name: "processData".to_string(),
238 file_path: "module.ts".to_string(),
239 },
240 ],
241 ®istry,
242 );
243 assert!(
244 !warnings.is_empty(),
245 "Should warn when caller and callee both modified. Warnings: {:?}",
246 warnings
247 );
248 let has_dep_warning = warnings.iter().any(|w| {
250 w.entity_name == "processData"
251 && matches!(w.kind, WarningKind::DependencyAlsoModified)
252 && w.related.iter().any(|r| r.name == "validateInput")
253 });
254 assert!(
255 has_dep_warning,
256 "Should warn that processData depends on validateInput"
257 );
258 }
259
260 #[test]
261 fn test_no_warning_unrelated_entities() {
262 let dir = setup_test_repo();
263 let registry = sem_core::parser::plugins::create_default_registry();
264 let warnings = validate_merge(
265 dir.path(),
266 &["module.ts".to_string()],
267 &[
268 ModifiedEntity {
269 name: "validateInput".to_string(),
270 file_path: "module.ts".to_string(),
271 },
272 ModifiedEntity {
273 name: "unrelated".to_string(),
274 file_path: "module.ts".to_string(),
275 },
276 ],
277 ®istry,
278 );
279 let cross_warnings: Vec<_> = warnings
281 .iter()
282 .filter(|w| {
283 (w.entity_name == "validateInput"
284 && w.related.iter().any(|r| r.name == "unrelated"))
285 || (w.entity_name == "unrelated"
286 && w.related.iter().any(|r| r.name == "validateInput"))
287 })
288 .collect();
289 assert!(
290 cross_warnings.is_empty(),
291 "Unrelated entities should not trigger cross-warnings"
292 );
293 }
294}