Skip to main content

code_system_graph_core/
corroboration.rs

1use std::path::PathBuf;
2
3use code_system_graph_model::RepoId;
4use serde::{Deserialize, Serialize};
5use tokio_util::sync::CancellationToken;
6
7use crate::{
8    AffectedTestsRequest, LocalCodeIntelligenceProvider, ProviderBudget, ProviderCapability, ProviderRequest, ProviderStatus, ResolveSymbolsRequest
9};
10
11/// Exact source symbol anchor selected by a focused extractor.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct SymbolAnchor {
14    /// Extracted symbol name or qualified name.
15    pub symbol: String,
16    /// Repository-relative source path.
17    pub source_path: String,
18    /// One-based source line.
19    pub start_line: usize,
20}
21
22/// Provider corroboration outcome for one source symbol.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case", tag = "status")]
25pub enum SymbolCorroboration {
26    /// Exactly one provider symbol matched the extracted name, path, and line.
27    Confirmed {
28        /// Extracted source symbol.
29        symbol: String,
30        /// Repository-relative source path.
31        source_path: String,
32        /// Provider-local identifier retained only as evidence metadata.
33        local_id: Option<String>,
34    },
35    /// No exact provider symbol matched all extracted coordinates.
36    Unresolved {
37        /// Extracted source symbol.
38        symbol: String,
39        /// Bounded reason.
40        reason: String,
41    },
42    /// More than one exact provider symbol matched.
43    Ambiguous {
44        /// Extracted source symbol.
45        symbol: String,
46        /// Number of exact candidates.
47        candidate_count: usize,
48    },
49}
50
51/// Bounded optional `CodeGraph` corroboration for one repository scan.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct CorroborationReport {
54    /// Exact provider capability report observed before focused requests.
55    #[serde(default)]
56    pub capability: Option<ProviderCapability>,
57    /// Symbol outcomes in input order.
58    pub symbols: Vec<SymbolCorroboration>,
59    /// Repository-relative tests reported as affected.
60    pub affected_tests: Vec<String>,
61    /// Explicit degradation messages; an empty result is never interpreted as safety.
62    pub degradations: Vec<String>,
63}
64
65/// Runs focused, best-effort symbol and affected-test corroboration.
66///
67/// Provider failures are returned as degradations so factual extraction can continue without
68/// treating unavailable local intelligence as negative evidence.
69pub async fn corroborate_repository(
70    provider: &dyn LocalCodeIntelligenceProvider,
71    repo_id: RepoId,
72    project_path: PathBuf,
73    anchors: &[SymbolAnchor],
74    changed_files: &[String],
75    budget: ProviderBudget,
76    cancellation: CancellationToken,
77) -> CorroborationReport {
78    let mut symbols = Vec::with_capacity(anchors.len());
79    let mut degradations = Vec::new();
80    let capability_result = provider
81        .probe(ProviderRequest {
82            repo_id: repo_id.clone(),
83            project_path: project_path.clone(),
84            budget,
85            cancellation: cancellation.clone(),
86        })
87        .await;
88    let capability = match capability_result {
89        Ok(capability) => Some(capability),
90        Err(error) => {
91            degradations.push(error.to_string());
92            None
93        }
94    };
95    if capability
96        .as_ref()
97        .is_none_or(|capability| capability.status != ProviderStatus::Available)
98    {
99        if let Some(capability) = &capability {
100            degradations.extend(
101                capability
102                    .degradations
103                    .iter()
104                    .map(|degradation| degradation.message.clone()),
105            );
106            degradations.push(format!(
107                "{} local intelligence status is {:?}",
108                capability.provider, capability.status
109            ));
110        }
111        symbols.extend(
112            anchors
113                .iter()
114                .map(|anchor| SymbolCorroboration::Unresolved {
115                    symbol: anchor.symbol.clone(),
116                    reason: "local intelligence provider is not available".to_owned(),
117                }),
118        );
119        degradations.sort();
120        degradations.dedup();
121        return CorroborationReport {
122            capability,
123            symbols,
124            affected_tests: Vec::new(),
125            degradations,
126        };
127    }
128    for anchor in anchors {
129        let (outcome, observed_degradations) = corroborate_symbol(
130            provider,
131            &repo_id,
132            &project_path,
133            anchor,
134            budget,
135            &cancellation,
136        )
137        .await;
138        symbols.push(outcome);
139        degradations.extend(observed_degradations);
140    }
141
142    let (affected_tests, affected_degradations) = corroborate_affected_tests(
143        provider,
144        repo_id,
145        project_path,
146        changed_files,
147        budget,
148        cancellation,
149    )
150    .await;
151    degradations.extend(affected_degradations);
152    CorroborationReport {
153        capability,
154        symbols,
155        affected_tests,
156        degradations,
157    }
158}
159
160async fn corroborate_symbol(
161    provider: &dyn LocalCodeIntelligenceProvider,
162    repo_id: &RepoId,
163    project_path: &std::path::Path,
164    anchor: &SymbolAnchor,
165    budget: ProviderBudget,
166    cancellation: &CancellationToken,
167) -> (SymbolCorroboration, Vec<String>) {
168    let request = ProviderRequest {
169        repo_id: repo_id.clone(),
170        project_path: project_path.to_path_buf(),
171        budget,
172        cancellation: cancellation.clone(),
173    };
174    let result = provider
175        .resolve_symbols(ResolveSymbolsRequest {
176            request,
177            query: anchor.symbol.clone(),
178        })
179        .await;
180    let Ok(result) = result else {
181        let error = result.err().map_or_else(
182            || "local intelligence unavailable".to_owned(),
183            |error| error.to_string(),
184        );
185        return (
186            SymbolCorroboration::Unresolved {
187                symbol: anchor.symbol.clone(),
188                reason: "local intelligence unavailable".to_owned(),
189            },
190            vec![error],
191        );
192    };
193    let exact = result
194        .symbols
195        .into_iter()
196        .filter(|candidate| {
197            symbol_name_matches(candidate, &anchor.symbol)
198                && portable_path(&candidate.file_path) == portable_path(&anchor.source_path)
199                && candidate.start_line == anchor.start_line
200        })
201        .collect::<Vec<_>>();
202    let outcome = match exact.as_slice() {
203        [candidate] => SymbolCorroboration::Confirmed {
204            symbol: anchor.symbol.clone(),
205            source_path: anchor.source_path.clone(),
206            local_id: candidate.local_id.clone(),
207        },
208        [] => SymbolCorroboration::Unresolved {
209            symbol: anchor.symbol.clone(),
210            reason: "provider returned no exact name/path/line match".to_owned(),
211        },
212        candidates => SymbolCorroboration::Ambiguous {
213            symbol: anchor.symbol.clone(),
214            candidate_count: candidates.len(),
215        },
216    };
217    let degradations = result
218        .execution
219        .degradations
220        .into_iter()
221        .map(|degradation| degradation.message)
222        .collect();
223    (outcome, degradations)
224}
225
226async fn corroborate_affected_tests(
227    provider: &dyn LocalCodeIntelligenceProvider,
228    repo_id: RepoId,
229    project_path: PathBuf,
230    changed_files: &[String],
231    budget: ProviderBudget,
232    cancellation: CancellationToken,
233) -> (Vec<String>, Vec<String>) {
234    if changed_files.is_empty() {
235        return (Vec::new(), Vec::new());
236    }
237    let request = ProviderRequest {
238        repo_id,
239        project_path,
240        budget,
241        cancellation,
242    };
243    match provider
244        .get_affected_tests(AffectedTestsRequest {
245            request,
246            changed_files: changed_files.to_vec(),
247            max_depth: 4,
248        })
249        .await
250    {
251        Ok(Some(mut result)) => {
252            result.affected_tests.sort();
253            result.affected_tests.dedup();
254            let degradations = result
255                .execution
256                .degradations
257                .into_iter()
258                .map(|degradation| degradation.message)
259                .collect();
260            (result.affected_tests, degradations)
261        }
262        Ok(None) => (
263            Vec::new(),
264            vec!["affected-test capability unavailable".to_owned()],
265        ),
266        Err(error) => (Vec::new(), vec![error.to_string()]),
267    }
268}
269
270fn symbol_name_matches(candidate: &crate::ResolvedSymbol, anchor: &str) -> bool {
271    candidate.name == anchor || candidate.qualified_name.as_deref() == Some(anchor)
272}
273
274fn portable_path(path: &str) -> String {
275    path.replace('\\', "/")
276}
277
278#[cfg(test)]
279mod tests {
280    use std::sync::atomic::{AtomicUsize, Ordering};
281
282    use async_trait::async_trait;
283    use code_system_graph_model::RepoId;
284    use tokio_util::sync::CancellationToken;
285
286    use super::{SymbolAnchor, SymbolCorroboration, corroborate_repository};
287    use crate::{
288        AffectedTestsRequest, AffectedTestsResult, LocalCodeIntelligenceProvider, LocalContextRequest, LocalContextResult, LocalImpactRequest, LocalImpactResult, LocalNeighborResult, LocalNeighborsRequest, ProviderBudget, ProviderCapability, ProviderError, ProviderExecution, ProviderStatus, ProviderTransport, ResolveSymbolsRequest, ResolveSymbolsResult, ResolvedSymbol
289    };
290
291    struct FakeProvider {
292        status: ProviderStatus,
293        operation_calls: AtomicUsize,
294    }
295
296    impl FakeProvider {
297        fn available() -> Self {
298            Self {
299                status: ProviderStatus::Available,
300                operation_calls: AtomicUsize::new(0),
301            }
302        }
303    }
304
305    #[async_trait]
306    impl LocalCodeIntelligenceProvider for FakeProvider {
307        fn provider_name(&self) -> &'static str {
308            "fake"
309        }
310
311        async fn probe(
312            &self,
313            _request: crate::ProviderRequest,
314        ) -> Result<ProviderCapability, ProviderError> {
315            Ok(ProviderCapability {
316                provider: "fake".to_owned(),
317                version: Some("1.0.0".to_owned()),
318                status: self.status,
319                tools: Vec::new(),
320                protocol_version: None,
321                operations: Vec::new(),
322                degradations: Vec::new(),
323                remediation: None,
324            })
325        }
326
327        async fn resolve_symbols(
328            &self,
329            input: ResolveSymbolsRequest,
330        ) -> Result<ResolveSymbolsResult, ProviderError> {
331            self.operation_calls.fetch_add(1, Ordering::Relaxed);
332            Ok(ResolveSymbolsResult {
333                symbols: vec![ResolvedSymbol {
334                    local_id: Some("local:1".to_owned()),
335                    name: input.query,
336                    qualified_name: None,
337                    kind: "function".to_owned(),
338                    file_path: "src/routes.rs".to_owned(),
339                    start_line: 10,
340                    score: Some(1.0),
341                }],
342                execution: execution(),
343            })
344        }
345
346        async fn get_local_neighbors(
347            &self,
348            _input: LocalNeighborsRequest,
349        ) -> Result<LocalNeighborResult, ProviderError> {
350            unreachable!("neighbors are not used by corroboration")
351        }
352
353        async fn get_local_impact(
354            &self,
355            _input: LocalImpactRequest,
356        ) -> Result<LocalImpactResult, ProviderError> {
357            unreachable!("impact is not used by corroboration")
358        }
359
360        async fn build_local_context(
361            &self,
362            _input: LocalContextRequest,
363        ) -> Result<LocalContextResult, ProviderError> {
364            unreachable!("context is not used by corroboration")
365        }
366
367        async fn get_affected_tests(
368            &self,
369            input: AffectedTestsRequest,
370        ) -> Result<Option<AffectedTestsResult>, ProviderError> {
371            self.operation_calls.fetch_add(1, Ordering::Relaxed);
372            Ok(Some(AffectedTestsResult {
373                changed_files: input.changed_files,
374                affected_tests: vec!["tests/routes.rs".to_owned()],
375                total_dependents_traversed: 1,
376                execution: execution(),
377            }))
378        }
379
380        async fn shutdown(&self) -> Result<(), ProviderError> {
381            Ok(())
382        }
383    }
384
385    fn execution() -> ProviderExecution {
386        ProviderExecution {
387            transport: ProviderTransport::Cli,
388            output_bytes: 1,
389            truncated: false,
390            degradations: Vec::new(),
391        }
392    }
393
394    #[tokio::test]
395    async fn corroboration_should_require_exact_name_path_and_line() {
396        let provider = FakeProvider::available();
397        let report = corroborate_repository(
398            &provider,
399            RepoId::new("repo:api"),
400            "/repo".into(),
401            &[SymbolAnchor {
402                symbol: "create_order".to_owned(),
403                source_path: "src/routes.rs".to_owned(),
404                start_line: 10,
405            }],
406            &["src/routes.rs".to_owned()],
407            ProviderBudget::default(),
408            CancellationToken::new(),
409        )
410        .await;
411
412        assert!(matches!(
413            report.symbols.as_slice(),
414            [SymbolCorroboration::Confirmed { .. }]
415        ));
416        assert_eq!(report.affected_tests, vec!["tests/routes.rs"]);
417        assert_eq!(provider.operation_calls.load(Ordering::Relaxed), 2);
418    }
419
420    #[tokio::test]
421    async fn unavailable_capability_should_skip_every_focused_operation() {
422        let provider = FakeProvider {
423            status: ProviderStatus::IndexMissing,
424            operation_calls: AtomicUsize::new(0),
425        };
426        let report = corroborate_repository(
427            &provider,
428            RepoId::new("repo:api"),
429            "/repo".into(),
430            &[SymbolAnchor {
431                symbol: "create_order".to_owned(),
432                source_path: "src/routes.rs".to_owned(),
433                start_line: 10,
434            }],
435            &["src/routes.rs".to_owned()],
436            ProviderBudget::default(),
437            CancellationToken::new(),
438        )
439        .await;
440
441        assert!(matches!(
442            report.symbols.as_slice(),
443            [SymbolCorroboration::Unresolved { .. }]
444        ));
445        assert!(report.affected_tests.is_empty());
446        assert_eq!(provider.operation_calls.load(Ordering::Relaxed), 0);
447    }
448}