Skip to main content

ucm_reason/
intent.rs

1//! Test intent generator — produces test recommendations from impact analysis.
2//!
3//! Outputs structured test intent (not test scripts):
4//! - What scenarios should be tested
5//! - What risks are introduced
6//! - Where confidence is high vs low
7//! - Existing coverage gaps
8//!
9//! Each recommendation includes an explanation chain
10//! showing WHY the system recommends testing this scenario.
11
12use crate::explanation::ExplanationChain;
13use crate::impact::ImpactReport;
14use serde::{Deserialize, Serialize};
15use ucm_graph_core::edge::ConfidenceTier;
16
17/// Complete test intent output — what to test and why.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct TestIntent {
20    /// High-confidence scenarios — definitely test these
21    pub high_confidence: Vec<TestScenario>,
22    /// Medium-confidence scenarios — recommended to test
23    pub medium_confidence: Vec<TestScenario>,
24    /// Low-confidence scenarios — test if time permits
25    pub low_confidence: Vec<TestScenario>,
26    /// Identified risks
27    pub risks: Vec<Risk>,
28    /// Coverage gaps identified
29    pub coverage_gaps: Vec<CoverageGap>,
30    /// Entities explicitly decided NOT to test, with reasoning
31    pub decided_not_to_test: Vec<SkippedEntity>,
32    /// Summary statistics
33    pub summary: TestIntentSummary,
34}
35
36/// A single test scenario recommendation.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct TestScenario {
39    /// What to test (human-readable)
40    pub description: String,
41    /// Why this should be tested
42    pub rationale: String,
43    /// Confidence that this scenario is necessary
44    pub confidence: f64,
45    /// Which impacted entity this relates to
46    pub related_entity: String,
47    /// Full reasoning chain
48    pub explanation_chain: ExplanationChain,
49}
50
51/// An identified risk from the change.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Risk {
54    pub severity: RiskSeverity,
55    pub description: String,
56    pub mitigation: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub enum RiskSeverity {
61    High,
62    Medium,
63    Low,
64}
65
66/// An entity explicitly decided NOT to test, with reasoning.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct SkippedEntity {
69    /// The entity we decided not to test
70    pub entity: String,
71    /// Why we decided not to test it
72    pub reason: String,
73    /// How confident we are that skipping is safe
74    pub confidence_of_safety: f64,
75}
76
77/// A gap in existing test coverage.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct CoverageGap {
80    pub entity: String,
81    pub description: String,
82    pub recommendation: String,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct TestIntentSummary {
87    pub total_scenarios: usize,
88    pub high_count: usize,
89    pub medium_count: usize,
90    pub low_count: usize,
91    pub risk_count: usize,
92}
93
94/// Generate test intent from an impact report.
95///
96/// This is the core reasoning function that transforms graph-based
97/// impact analysis into actionable test recommendations.
98pub fn generate_test_intent(report: &ImpactReport) -> TestIntent {
99    let mut high = Vec::new();
100    let mut medium = Vec::new();
101    let mut low = Vec::new();
102    let mut risks = Vec::new();
103    let mut coverage_gaps = Vec::new();
104
105    // Generate scenarios for directly impacted entities
106    for impact in &report.direct_impacts {
107        let tier = ConfidenceTier::from_score(impact.confidence);
108
109        let scenario = TestScenario {
110            description: format!(
111                "Verify {} still functions correctly after change",
112                impact.name
113            ),
114            rationale: impact.reason.clone(),
115            confidence: impact.confidence,
116            related_entity: impact.name.clone(),
117            explanation_chain: impact.explanation_chain.clone(),
118        };
119
120        match tier {
121            ConfidenceTier::High => high.push(scenario),
122            ConfidenceTier::Medium => medium.push(scenario),
123            ConfidenceTier::Low => low.push(scenario),
124        }
125
126        // Add regression risk for direct impacts
127        risks.push(Risk {
128            severity: RiskSeverity::High,
129            description: format!(
130                "{} directly depends on changed code — regression risk if behavior changes",
131                impact.name
132            ),
133            mitigation: format!(
134                "Run existing tests for {} and verify expected behavior",
135                impact.name
136            ),
137        });
138
139        // Generate negative test scenario for direct impacts
140        high.push(TestScenario {
141            description: format!(
142                "Verify {} properly handles error cases after change",
143                impact.name
144            ),
145            rationale: "Direct dependency means error handling paths may also be affected".into(),
146            confidence: impact.confidence * 0.9,
147            related_entity: impact.name.clone(),
148            explanation_chain: {
149                let mut chain =
150                    ExplanationChain::new(format!("Error handling test for {}", impact.name));
151                chain.add_step(
152                    "Direct dependency on changed code",
153                    "Error paths may also be affected by the change",
154                    impact.confidence * 0.9,
155                );
156                chain
157            },
158        });
159    }
160
161    // Generate scenarios for indirectly impacted entities
162    for impact in &report.indirect_impacts {
163        let tier = ConfidenceTier::from_score(impact.confidence);
164
165        let scenario = TestScenario {
166            description: format!(
167                "Verify {} end-to-end flow still works (transitive dependency via {} hops)",
168                impact.name, impact.depth
169            ),
170            rationale: impact.reason.clone(),
171            confidence: impact.confidence,
172            related_entity: impact.name.clone(),
173            explanation_chain: impact.explanation_chain.clone(),
174        };
175
176        match tier {
177            ConfidenceTier::High => high.push(scenario),
178            ConfidenceTier::Medium => medium.push(scenario),
179            ConfidenceTier::Low => low.push(scenario),
180        }
181
182        // Indirect impacts with high traffic → medium risk
183        if impact.confidence > 0.7 {
184            risks.push(Risk {
185                severity: RiskSeverity::Medium,
186                description: format!(
187                    "{} is indirectly affected via {}-hop chain with {:.0}% confidence",
188                    impact.name,
189                    impact.depth,
190                    impact.confidence * 100.0
191                ),
192                mitigation: format!(
193                    "Integration test covering the path: {}",
194                    impact.path.join(" → ")
195                ),
196            });
197        }
198    }
199
200    // Check for coverage gaps among impacted entities
201    for impact in report
202        .direct_impacts
203        .iter()
204        .chain(report.indirect_impacts.iter())
205    {
206        // If we don't see any test entities connected, flag as gap
207        coverage_gaps.push(CoverageGap {
208            entity: impact.name.clone(),
209            description: format!(
210                "{} is impacted but has no linked test coverage in the graph",
211                impact.name
212            ),
213            recommendation: format!(
214                "Add test coverage for {} focusing on the changed behavior",
215                impact.name
216            ),
217        });
218    }
219
220    // Populate "decided not to test" from not-impacted entities
221    let decided_not_to_test: Vec<SkippedEntity> = report
222        .not_impacted
223        .iter()
224        .map(|entry| SkippedEntity {
225            entity: entry.name.clone(),
226            reason: entry.reason.clone(),
227            confidence_of_safety: entry.confidence,
228        })
229        .collect();
230
231    // Add risk for ambiguities
232    for ambiguity in &report.ambiguities {
233        risks.push(Risk {
234            severity: RiskSeverity::High,
235            description: format!("Ambiguity: {}", ambiguity.description),
236            mitigation: ambiguity.recommendation.clone(),
237        });
238    }
239
240    let summary = TestIntentSummary {
241        total_scenarios: high.len() + medium.len() + low.len(),
242        high_count: high.len(),
243        medium_count: medium.len(),
244        low_count: low.len(),
245        risk_count: risks.len(),
246    };
247
248    TestIntent {
249        high_confidence: high,
250        medium_confidence: medium,
251        low_confidence: low,
252        risks,
253        coverage_gaps,
254        decided_not_to_test,
255        summary,
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::impact::analyze_impact;
263    use ucm_graph_core::edge::*;
264    use ucm_graph_core::entity::*;
265    use ucm_graph_core::graph::UcmGraph;
266
267    #[test]
268    fn test_generate_test_intent() {
269        let mut graph = UcmGraph::new();
270
271        // Build same test graph as impact tests
272        for (file, symbol, name) in &[
273            ("src/auth/service.ts", "validateToken", "validateToken"),
274            ("src/api/middleware.ts", "authMiddleware", "authMiddleware"),
275            (
276                "src/payments/checkout.ts",
277                "processPayment",
278                "processPayment",
279            ),
280        ] {
281            graph
282                .add_entity(UcmEntity::new(
283                    EntityId::local(file, symbol),
284                    EntityKind::Function {
285                        is_async: true,
286                        parameter_count: 1,
287                        return_type: None,
288                    },
289                    *name,
290                    *file,
291                    "typescript",
292                    DiscoverySource::StaticAnalysis,
293                ))
294                .unwrap();
295        }
296
297        graph
298            .add_relationship(
299                &EntityId::local("src/api/middleware.ts", "authMiddleware"),
300                &EntityId::local("src/auth/service.ts", "validateToken"),
301                UcmEdge::new(
302                    RelationType::Imports,
303                    DiscoverySource::StaticAnalysis,
304                    0.95,
305                    "imports",
306                ),
307            )
308            .unwrap();
309
310        graph
311            .add_relationship(
312                &EntityId::local("src/payments/checkout.ts", "processPayment"),
313                &EntityId::local("src/api/middleware.ts", "authMiddleware"),
314                UcmEdge::new(
315                    RelationType::DependsOn,
316                    DiscoverySource::StaticAnalysis,
317                    0.80,
318                    "depends",
319                ),
320            )
321            .unwrap();
322
323        let changed = vec![EntityId::local("src/auth/service.ts", "validateToken")];
324        let report = analyze_impact(&graph, &changed, 0.1, 10);
325        let intent = generate_test_intent(&report);
326
327        // Should have scenarios
328        assert!(intent.summary.total_scenarios > 0);
329
330        // Should have risks
331        assert!(!intent.risks.is_empty());
332
333        // High confidence should include scenarios for direct impacts
334        assert!(!intent.high_confidence.is_empty());
335
336        // All scenarios should have explanation chains
337        for scenario in &intent.high_confidence {
338            assert!(!scenario.explanation_chain.steps.is_empty());
339        }
340
341        // Should be serializable
342        let json = serde_json::to_string_pretty(&intent).unwrap();
343        assert!(json.contains("explanation_chain"));
344        assert!(json.contains("high_confidence"));
345        assert!(json.contains("decided_not_to_test"));
346    }
347}