Skip to main content

weave_core/
validate.rs

1//! Post-merge semantic validation.
2//!
3//! After a syntactically clean merge, entities may still be semantically
4//! incompatible. For example, if function A calls function B and both were
5//! modified by different agents, the merge may succeed syntactically but B's
6//! contract (return type, parameters, side effects) may have changed in ways
7//! that break A.
8//!
9//! This module flags such "semantic risk" cases as warnings, not errors.
10//! The merge still succeeds — this is advisory.
11
12use std::collections::HashSet;
13
14use sem_core::parser::graph::EntityGraph;
15use sem_core::parser::registry::ParserRegistry;
16
17/// A warning about a potentially unsafe merge.
18#[derive(Debug, Clone)]
19pub struct SemanticWarning {
20    /// The entity that was auto-merged and may be at risk.
21    pub entity_name: String,
22    pub entity_type: String,
23    pub file_path: String,
24    /// The kind of semantic risk detected.
25    pub kind: WarningKind,
26    /// Related entities involved in the risk.
27    pub related: Vec<RelatedEntity>,
28}
29
30#[derive(Debug, Clone)]
31pub enum WarningKind {
32    /// Entity references another entity that was also modified in this merge.
33    /// The referenced entity's contract may have changed.
34    DependencyAlsoModified,
35    /// Entity is depended on by another entity that was also modified.
36    /// The dependent may have adapted to old behavior.
37    DependentAlsoModified,
38    /// The merged output failed to parse — syntactically broken merge result.
39    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
49/// Validate a merge result for semantic risks.
50///
51/// Takes the set of entity names that were auto-merged (modified by one or both
52/// branches) and uses the entity dependency graph to check for cross-references
53/// between modified entities.
54pub 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    // Build the dependency graph
65    let graph = EntityGraph::build(repo_root, file_paths, registry);
66
67    // Build a set of modified entity IDs for quick lookup
68    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        // Check: does this entity depend on another modified entity?
88        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        // Check: is this entity depended on by another modified entity?
106        let dependents = graph.get_dependents(entity_id);
107        for dep in &dependents {
108            if modified_ids.contains(&dep.id) && dep.id != *entity_id {
109                // Only add if we haven't already covered this from the other direction
110                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/// A modified entity descriptor, used as input to validation.
136#[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        // Create a TS file where function A calls function B
186        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            &registry,
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            &registry,
242        );
243        assert!(
244            !warnings.is_empty(),
245            "Should warn when caller and callee both modified. Warnings: {:?}",
246            warnings
247        );
248        // processData calls validateInput, so there should be a warning
249        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            &registry,
278        );
279        // validateInput and unrelated don't reference each other
280        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}