Skip to main content

mockforge_intelligence/ai_contract_diff/
semantic_analyzer.rs

1//! Semantic drift analysis for contract diffs
2//!
3//! This module provides Layer 2 semantic analysis that detects meaning changes
4//! beyond structural diffs, such as description changes, enum narrowing,
5//! nullable changes hidden behind oneOf, and error code removals.
6
7use super::types::{ContractDiffConfig, Mismatch, MismatchSeverity, MismatchType};
8use crate::intelligent_behavior::config::BehaviorModelConfig;
9use crate::intelligent_behavior::llm_client::LlmClient;
10use crate::intelligent_behavior::types::LlmGenerationRequest;
11use mockforge_foundation::Result;
12use mockforge_openapi::OpenApiSpec;
13// Semantic drift data types re-exported from foundation.
14pub use mockforge_foundation::contract_diff_types::{SemanticChangeType, SemanticDriftResult};
15use openapiv3;
16use serde_json::Value;
17use std::collections::HashMap;
18
19/// Semantic analyzer for detecting meaning changes
20pub struct SemanticAnalyzer {
21    /// LLM client for semantic analysis
22    llm_client: Option<LlmClient>,
23    /// Configuration
24    config: ContractDiffConfig,
25}
26
27impl SemanticAnalyzer {
28    /// Create a new semantic analyzer
29    pub fn new(config: ContractDiffConfig) -> Result<Self> {
30        let llm_client = if config.semantic_analysis_enabled {
31            let llm_config = BehaviorModelConfig {
32                llm_provider: config.llm_provider.clone(),
33                model: config.llm_model.clone(),
34                api_key: config.api_key.clone(),
35                api_endpoint: None,
36                temperature: 0.3, // Lower temperature for more precise semantic analysis
37                max_tokens: 3000,
38                rules: crate::intelligent_behavior::BehaviorRules::default(),
39                seed: None,
40            };
41
42            Some(LlmClient::new(llm_config))
43        } else {
44            None
45        };
46
47        Ok(Self { llm_client, config })
48    }
49
50    /// Analyze semantic drift between two contract states
51    ///
52    /// This is Layer 2 analysis that runs after structural diff to detect
53    /// meaning changes that might not be structurally breaking but are
54    /// semantically significant.
55    pub async fn analyze_semantic_drift(
56        &self,
57        before_spec: &OpenApiSpec,
58        after_spec: &OpenApiSpec,
59        endpoint_path: &str,
60        method: &str,
61    ) -> Result<Option<SemanticDriftResult>> {
62        if !self.config.semantic_analysis_enabled {
63            return Ok(None);
64        }
65
66        // Extract relevant schemas for the endpoint
67        let before_schema = self.extract_endpoint_schema(before_spec, endpoint_path, method);
68        let after_schema = self.extract_endpoint_schema(after_spec, endpoint_path, method);
69
70        if before_schema.is_none() || after_schema.is_none() {
71            return Ok(None);
72        }
73
74        let before = before_schema.unwrap();
75        let after = after_schema.unwrap();
76
77        // Detect semantic changes using rule-based analysis first
78        let rule_based_changes = self.detect_rule_based_changes(&before, &after);
79
80        // If we have an LLM client, use it for deeper semantic analysis
81        if let Some(ref llm_client) = self.llm_client {
82            let llm_result = self
83                .analyze_with_llm(llm_client, &before, &after, endpoint_path, method)
84                .await?;
85
86            // Combine rule-based and LLM results
87            Ok(Some(self.combine_results(rule_based_changes, llm_result, before, after)))
88        } else {
89            // Use only rule-based analysis
90            if rule_based_changes.is_empty() {
91                return Ok(None);
92            }
93
94            // Create result from rule-based changes only
95            let change_type = self.determine_change_type(&rule_based_changes);
96            let semantic_confidence = 0.6; // Lower confidence without LLM
97            let soft_breaking_score = self.calculate_soft_breaking_score(&rule_based_changes);
98
99            Ok(Some(SemanticDriftResult {
100                semantic_confidence,
101                soft_breaking_score,
102                change_type,
103                llm_analysis: serde_json::json!({}),
104                before_semantic_state: before,
105                after_semantic_state: after,
106                semantic_mismatches: rule_based_changes,
107            }))
108        }
109    }
110
111    /// Extract schema for a specific endpoint
112    fn extract_endpoint_schema(
113        &self,
114        spec: &OpenApiSpec,
115        endpoint_path: &str,
116        method: &str,
117    ) -> Option<Value> {
118        // This is a simplified extraction - in practice, you'd properly
119        // navigate the OpenAPI spec structure
120        spec.spec.paths.paths.get(endpoint_path).and_then(|path_item| {
121            path_item.as_item().and_then(|item| {
122                // Get operation based on method
123                let operation = match method.to_uppercase().as_str() {
124                    "GET" => item.get.as_ref(),
125                    "POST" => item.post.as_ref(),
126                    "PUT" => item.put.as_ref(),
127                    "DELETE" => item.delete.as_ref(),
128                    "PATCH" => item.patch.as_ref(),
129                    "HEAD" => item.head.as_ref(),
130                    "OPTIONS" => item.options.as_ref(),
131                    "TRACE" => item.trace.as_ref(),
132                    _ => None,
133                }?;
134
135                operation.responses.responses.get(&openapiv3::StatusCode::Code(200)).and_then(
136                    |resp| {
137                        resp.as_item().and_then(|r| {
138                            r.content.get("application/json").and_then(|media| {
139                                media
140                                    .schema
141                                    .as_ref()
142                                    .map(|s| serde_json::to_value(s).unwrap_or_default())
143                            })
144                        })
145                    },
146                )
147            })
148        })
149    }
150
151    /// Detect rule-based semantic changes
152    fn detect_rule_based_changes(&self, before: &Value, after: &Value) -> Vec<Mismatch> {
153        let mut mismatches = Vec::new();
154
155        // Detect description changes
156        mismatches.extend(self.detect_description_changes(before, after));
157
158        // Detect enum narrowing
159        mismatches.extend(self.detect_enum_narrowing(before, after));
160
161        // Detect nullable changes
162        mismatches.extend(self.detect_nullable_changes(before, after));
163
164        // Detect error code changes (if error responses are in schema)
165        mismatches.extend(self.detect_error_code_changes(before, after));
166
167        mismatches
168    }
169
170    /// Detect description meaning changes
171    fn detect_description_changes(&self, before: &Value, after: &Value) -> Vec<Mismatch> {
172        let mut mismatches = Vec::new();
173
174        // Compare descriptions at schema level
175        if let (Some(before_desc), Some(after_desc)) = (
176            before.get("description").and_then(|v| v.as_str()),
177            after.get("description").and_then(|v| v.as_str()),
178        ) {
179            if before_desc != after_desc {
180                // Check if it's a significant meaning change (not just wording)
181                let is_significant = self.is_description_meaning_change(before_desc, after_desc);
182
183                if is_significant {
184                    mismatches.push(Mismatch {
185                        mismatch_type: MismatchType::SemanticDescriptionChange,
186                        path: "description".to_string(),
187                        method: None,
188                        expected: Some(before_desc.to_string()),
189                        actual: Some(after_desc.to_string()),
190                        description: format!(
191                            "Description meaning changed: '{}' → '{}'",
192                            before_desc, after_desc
193                        ),
194                        severity: MismatchSeverity::Medium,
195                        confidence: 0.7,
196                        context: HashMap::new(),
197                    });
198                }
199            }
200        }
201
202        mismatches
203    }
204
205    /// Check if description change is a meaning change (simplified heuristic)
206    fn is_description_meaning_change(&self, before: &str, after: &str) -> bool {
207        // Simple heuristic: if more than 30% of words changed, consider it significant
208        let before_words: Vec<&str> = before.split_whitespace().collect();
209        let after_words: Vec<&str> = after.split_whitespace().collect();
210
211        if before_words.is_empty() || after_words.is_empty() {
212            return true; // Empty to non-empty or vice versa is significant
213        }
214
215        let common_words: usize = before_words.iter().filter(|w| after_words.contains(w)).count();
216
217        let change_ratio =
218            1.0 - (common_words as f64 / before_words.len().max(after_words.len()) as f64);
219        change_ratio > 0.3
220    }
221
222    /// Detect enum narrowing (values removed)
223    fn detect_enum_narrowing(&self, before: &Value, after: &Value) -> Vec<Mismatch> {
224        let mut mismatches = Vec::new();
225
226        if let (Some(before_enum), Some(after_enum)) = (
227            before.get("enum").and_then(|v| v.as_array()),
228            after.get("enum").and_then(|v| v.as_array()),
229        ) {
230            let before_set: std::collections::HashSet<&Value> = before_enum.iter().collect();
231            let after_set: std::collections::HashSet<&Value> = after_enum.iter().collect();
232
233            let removed: Vec<_> = before_set.difference(&after_set).collect();
234
235            if !removed.is_empty() {
236                mismatches.push(Mismatch {
237                    mismatch_type: MismatchType::SemanticEnumNarrowing,
238                    path: "enum".to_string(),
239                    method: None,
240                    expected: Some(format!("{:?}", before_enum)),
241                    actual: Some(format!("{:?}", after_enum)),
242                    description: format!(
243                        "Enum values narrowed: {} value(s) removed",
244                        removed.len()
245                    ),
246                    severity: MismatchSeverity::High,
247                    confidence: 1.0, // Structural change is certain
248                    context: HashMap::new(),
249                });
250            }
251        }
252
253        mismatches
254    }
255
256    /// Detect nullable changes hidden behind oneOf/anyOf
257    fn detect_nullable_changes(&self, before: &Value, after: &Value) -> Vec<Mismatch> {
258        let mut mismatches = Vec::new();
259
260        // Check if nullable changed
261        let before_nullable = before.get("nullable").and_then(|v| v.as_bool()).unwrap_or(false);
262        let after_nullable = after.get("nullable").and_then(|v| v.as_bool()).unwrap_or(false);
263
264        if before_nullable && !after_nullable {
265            // Check if it's hidden behind oneOf/anyOf
266            let is_hidden = after.get("oneOf").is_some() || after.get("anyOf").is_some();
267
268            if is_hidden {
269                mismatches.push(Mismatch {
270                    mismatch_type: MismatchType::SemanticNullabilityChange,
271                    path: "nullable".to_string(),
272                    method: None,
273                    expected: Some("nullable: true".to_string()),
274                    actual: Some("nullable: false (hidden behind oneOf/anyOf)".to_string()),
275                    description:
276                        "Field became non-nullable but change is hidden behind oneOf/anyOf"
277                            .to_string(),
278                    severity: MismatchSeverity::High,
279                    confidence: 0.8,
280                    context: HashMap::new(),
281                });
282            }
283        }
284
285        mismatches
286    }
287
288    /// Detect error code changes (4xx/5xx status codes removed between versions)
289    ///
290    /// Extracts error status codes from the `responses` object in both schema
291    /// snapshots and reports any codes present in `before` but missing in `after`.
292    fn detect_error_code_changes(&self, before: &Value, after: &Value) -> Vec<Mismatch> {
293        let mut mismatches = Vec::new();
294
295        let before_codes = Self::extract_error_status_codes(before);
296        let after_codes = Self::extract_error_status_codes(after);
297
298        let removed: Vec<&String> =
299            before_codes.iter().filter(|c| !after_codes.contains(*c)).collect();
300
301        if !removed.is_empty() {
302            mismatches.push(Mismatch {
303                mismatch_type: MismatchType::SemanticErrorCodeRemoved,
304                path: "responses".to_string(),
305                method: None,
306                expected: Some(format!("{:?}", before_codes)),
307                actual: Some(format!("{:?}", after_codes)),
308                description: format!(
309                    "Error status code(s) removed: {}",
310                    removed.iter().map(|c| c.as_str()).collect::<Vec<_>>().join(", ")
311                ),
312                severity: MismatchSeverity::High,
313                confidence: 1.0,
314                context: HashMap::new(),
315            });
316        }
317
318        mismatches
319    }
320
321    /// Extract error status codes (4xx/5xx) from a schema value's `responses` map
322    fn extract_error_status_codes(schema: &Value) -> Vec<String> {
323        let mut codes = Vec::new();
324        if let Some(responses) = schema.get("responses").and_then(|v| v.as_object()) {
325            for key in responses.keys() {
326                // Match 4xx and 5xx status codes
327                if let Some(first_char) = key.chars().next() {
328                    if (first_char == '4' || first_char == '5')
329                        && key.len() == 3
330                        && key.chars().all(|c| c.is_ascii_digit())
331                    {
332                        codes.push(key.clone());
333                    }
334                }
335            }
336        }
337        codes.sort();
338        codes
339    }
340
341    /// Analyze with LLM for deeper semantic understanding
342    async fn analyze_with_llm(
343        &self,
344        llm_client: &LlmClient,
345        before: &Value,
346        after: &Value,
347        endpoint_path: &str,
348        method: &str,
349    ) -> Result<Value> {
350        let prompt = self.build_semantic_analysis_prompt(before, after, endpoint_path, method);
351
352        let request = LlmGenerationRequest::new(self.get_system_prompt(), prompt)
353            .with_temperature(0.3)
354            .with_max_tokens(3000);
355
356        let response = llm_client.generate(&request).await?;
357
358        // Response is already a serde_json::Value, extract fields
359        let analysis = response
360            .get("analysis")
361            .and_then(|v| v.as_str())
362            .map(|s| s.to_string())
363            .unwrap_or_else(|| serde_json::to_string(&response).unwrap_or_default());
364
365        let confidence = response.get("confidence").and_then(|v| v.as_f64()).unwrap_or(0.5);
366
367        let soft_breaking_score =
368            response.get("soft_breaking_score").and_then(|v| v.as_f64()).unwrap_or(0.5);
369
370        Ok(serde_json::json!({
371            "analysis": analysis,
372            "confidence": confidence,
373            "soft_breaking_score": soft_breaking_score
374        }))
375    }
376
377    /// Build prompt for semantic analysis
378    fn build_semantic_analysis_prompt(
379        &self,
380        before: &Value,
381        after: &Value,
382        endpoint_path: &str,
383        method: &str,
384    ) -> String {
385        format!(
386            r#"Analyze the semantic differences between these two API contract schemas for endpoint {} {}.
387
388Before schema:
389{}
390
391After schema:
392{}
393
394Please identify:
3951. Any changes in meaning or semantics (not just structural changes)
3962. Description changes that alter the intended behavior
3973. Enum narrowing or constraint tightening
3984. Nullable changes that might break clients
3995. Error code removals
4006. Any "soft-breaking" changes that won't cause immediate failures but will cause issues
401
402Provide your analysis in JSON format with:
403- semantic_confidence: 0.0-1.0
404- soft_breaking_score: 0.0-1.0
405- change_type: one of the semantic change types
406- reasoning: detailed explanation
407- detected_changes: array of specific changes found"#,
408            method,
409            endpoint_path,
410            serde_json::to_string_pretty(before).unwrap_or_default(),
411            serde_json::to_string_pretty(after).unwrap_or_default()
412        )
413    }
414
415    /// Get system prompt for semantic analysis
416    fn get_system_prompt(&self) -> String {
417        "You are an expert API contract analyst specializing in detecting semantic drift and soft-breaking changes in API contracts. Your analysis helps teams understand when API changes might break clients even if they're not structurally breaking.".to_string()
418    }
419
420    /// Combine rule-based and LLM results
421    fn combine_results(
422        &self,
423        rule_based: Vec<Mismatch>,
424        llm_result: Value,
425        before: Value,
426        after: Value,
427    ) -> SemanticDriftResult {
428        let semantic_confidence =
429            llm_result.get("semantic_confidence").and_then(|v| v.as_f64()).unwrap_or(0.7);
430
431        let soft_breaking_score =
432            llm_result.get("soft_breaking_score").and_then(|v| v.as_f64()).unwrap_or(0.5);
433
434        let change_type_str = llm_result
435            .get("change_type")
436            .and_then(|v| v.as_str())
437            .unwrap_or("meaning_shift");
438
439        let change_type = match change_type_str {
440            "description_change" => SemanticChangeType::DescriptionChange,
441            "enum_narrowing" => SemanticChangeType::EnumNarrowing,
442            "nullable_change" => SemanticChangeType::NullableChange,
443            "error_code_removed" => SemanticChangeType::ErrorCodeRemoved,
444            "semantic_constraint_change" => SemanticChangeType::SemanticConstraintChange,
445            "soft_breaking_change" => SemanticChangeType::SoftBreakingChange,
446            _ => SemanticChangeType::MeaningShift,
447        };
448
449        // Merge rule-based mismatches with any from LLM
450        let semantic_mismatches = rule_based;
451
452        SemanticDriftResult {
453            semantic_confidence,
454            soft_breaking_score,
455            change_type,
456            llm_analysis: llm_result,
457            before_semantic_state: before,
458            after_semantic_state: after,
459            semantic_mismatches,
460        }
461    }
462
463    /// Determine change type from mismatches
464    fn determine_change_type(&self, mismatches: &[Mismatch]) -> SemanticChangeType {
465        for mismatch in mismatches {
466            match mismatch.mismatch_type {
467                MismatchType::SemanticDescriptionChange => {
468                    return SemanticChangeType::DescriptionChange
469                }
470                MismatchType::SemanticEnumNarrowing => return SemanticChangeType::EnumNarrowing,
471                MismatchType::SemanticNullabilityChange => {
472                    return SemanticChangeType::NullableChange
473                }
474                MismatchType::SemanticErrorCodeRemoved => {
475                    return SemanticChangeType::ErrorCodeRemoved
476                }
477                _ => {}
478            }
479        }
480
481        SemanticChangeType::MeaningShift
482    }
483
484    /// Calculate soft-breaking score
485    fn calculate_soft_breaking_score(&self, mismatches: &[Mismatch]) -> f64 {
486        if mismatches.is_empty() {
487            return 0.0;
488        }
489
490        // Higher score for more severe mismatches
491        let total_score: f64 = mismatches
492            .iter()
493            .map(|m| {
494                let severity_score = match m.severity {
495                    MismatchSeverity::Critical => 1.0,
496                    MismatchSeverity::High => 0.8,
497                    MismatchSeverity::Medium => 0.6,
498                    MismatchSeverity::Low => 0.4,
499                    MismatchSeverity::Info => 0.2,
500                };
501                severity_score * m.confidence
502            })
503            .sum();
504
505        (total_score / mismatches.len() as f64).min(1.0)
506    }
507}