Skip to main content

code_system_graph_core/
test_links.rs

1use std::collections::BTreeMap;
2
3use code_system_graph_model::{
4    Edge, EdgeId, EdgeKind, EpistemicStatus, Evidence, EvidenceId, Node, NodeId, NodeKind, Provenance, RepoId, stable_id
5};
6
7use crate::linker::{http_link_ambiguity, sort_http_ambiguities};
8use crate::{
9    BoundaryRole, ContractImplementationConfig, HttpBoundary, HttpLinkResolution, IntegrationTestConfig, LinkError, normalize_http_path
10};
11
12/// Declared cross-language test case and its validated HTTP target.
13#[derive(Debug, Clone, PartialEq)]
14pub struct DeclaredTestCase {
15    /// Federated test-case node.
16    pub node: Node,
17    /// Canonical target HTTP method.
18    pub method: String,
19    /// Canonical target HTTP path.
20    pub path: String,
21    /// Evidence from the test source declaration.
22    pub evidence: Evidence,
23}
24
25/// Declared source symbol and the HTTP contract it implements.
26#[derive(Debug, Clone, PartialEq)]
27pub struct DeclaredImplementation {
28    /// Lightweight source symbol node.
29    pub node: Node,
30    /// Canonical implemented HTTP method.
31    pub method: String,
32    /// Canonical implemented HTTP path.
33    pub path: String,
34    /// Evidence from the source declaration and file fingerprint.
35    pub evidence: Evidence,
36}
37
38/// Creates a test-case node from strict declared metadata.
39#[must_use]
40pub fn declared_test_case(
41    repo_id: RepoId,
42    config: &IntegrationTestConfig,
43    content_hash: Option<String>,
44) -> DeclaredTestCase {
45    let method = config.validates.method.trim().to_ascii_uppercase();
46    let path = normalize_http_path(&config.validates.path);
47    let stable_key = format!(
48        "test:{}:{}:{}:{}:{}",
49        repo_id.as_str(),
50        config.language.trim().to_ascii_lowercase(),
51        config.framework.trim().to_ascii_lowercase(),
52        config.path,
53        config.name
54    );
55    let evidence_key = format!("{stable_key}:{method}:{path}");
56    DeclaredTestCase {
57        node: Node {
58            id: NodeId::new(stable_id("node", &stable_key)),
59            kind: NodeKind::TestCase,
60            repo_id: Some(repo_id.clone()),
61            stable_key,
62            label: format!("{}/{}::{}", config.language, config.framework, config.name),
63        },
64        method,
65        path,
66        evidence: Evidence {
67            id: EvidenceId::new(stable_id("evidence", &evidence_key)),
68            repo_id: Some(repo_id),
69            file_path: Some(config.path.clone()),
70            start_line: None,
71            end_line: None,
72            extractor: "code-system-graph.tests.declared".to_owned(),
73            extractor_version: env!("CARGO_PKG_VERSION").to_owned(),
74            provenance: Provenance::Declared,
75            confidence: 1.0,
76            observed_at_commit: None,
77            content_hash,
78            note: Some(format!(
79                "{} test declared with {}",
80                config.language, config.framework
81            )),
82        },
83    }
84}
85
86/// Creates a lightweight implementation anchor from strict declared metadata.
87#[must_use]
88pub fn declared_implementation(
89    repo_id: RepoId,
90    config: &ContractImplementationConfig,
91    content_hash: Option<String>,
92) -> DeclaredImplementation {
93    let method = config.implements.method.trim().to_ascii_uppercase();
94    let path = normalize_http_path(&config.implements.path);
95    let stable_key = format!(
96        "symbol:{}:{}:{}:{}",
97        repo_id.as_str(),
98        config.language.trim().to_ascii_lowercase(),
99        config.path,
100        config.symbol
101    );
102    let evidence_key = format!("{stable_key}:{method}:{path}");
103    DeclaredImplementation {
104        node: Node {
105            id: NodeId::new(stable_id("node", &stable_key)),
106            kind: NodeKind::SymbolRef,
107            repo_id: Some(repo_id.clone()),
108            stable_key,
109            label: format!("{}::{}", config.language, config.symbol),
110        },
111        method,
112        path,
113        evidence: Evidence {
114            id: EvidenceId::new(stable_id("evidence", &evidence_key)),
115            repo_id: Some(repo_id),
116            file_path: Some(config.path.clone()),
117            start_line: None,
118            end_line: None,
119            extractor: "code-system-graph.implementations.declared".to_owned(),
120            extractor_version: env!("CARGO_PKG_VERSION").to_owned(),
121            provenance: Provenance::Declared,
122            confidence: 1.0,
123            observed_at_commit: None,
124            content_hash,
125            note: Some(format!(
126                "{} symbol declared as contract implementation",
127                config.language
128            )),
129        },
130    }
131}
132
133/// Links declared tests to exact HTTP providers with bilateral evidence.
134///
135/// Tests with no observed provider remain unlinked instead of inventing a target.
136///
137/// Duplicate providers remain fail-closed for compatibility. Use
138/// [`link_declared_tests_with_ambiguities`] to preserve ambiguities as data while continuing with
139/// unrelated contracts.
140///
141/// # Errors
142///
143/// Returns [`LinkError::AmbiguousProvider`] instead of silently omitting an ambiguous relationship.
144pub fn link_declared_tests(
145    tests: &[DeclaredTestCase],
146    boundaries: &[HttpBoundary],
147) -> Result<Vec<Edge>, LinkError> {
148    link_declared_tests_with_ambiguities(tests, boundaries).into_legacy_result()
149}
150
151/// Links declared tests while preserving duplicate-provider decisions.
152#[must_use]
153pub fn link_declared_tests_with_ambiguities(
154    tests: &[DeclaredTestCase],
155    boundaries: &[HttpBoundary],
156) -> HttpLinkResolution {
157    let mut providers: BTreeMap<(&str, &str), Vec<&HttpBoundary>> = BTreeMap::new();
158    for provider in boundaries
159        .iter()
160        .filter(|boundary| boundary.role == BoundaryRole::Provider)
161    {
162        let candidates = providers
163            .entry((&provider.method, &provider.path))
164            .or_default();
165        if let Some(existing) = candidates
166            .iter()
167            .position(|candidate| candidate.node.id == provider.node.id)
168        {
169            if provider.evidence.confidence > candidates[existing].evidence.confidence {
170                candidates[existing] = provider;
171            }
172        } else {
173            candidates.push(provider);
174        }
175    }
176    let mut edges = Vec::new();
177    let mut ambiguities = Vec::new();
178    for test in tests {
179        let Some(candidates) = providers.get(&(test.method.as_str(), test.path.as_str())) else {
180            continue;
181        };
182        if candidates.len() > 1 {
183            ambiguities.push(http_link_ambiguity(
184                &test.method,
185                &test.path,
186                candidates.iter().map(|candidate| &candidate.node.id),
187            ));
188            continue;
189        }
190        let provider = candidates[0];
191        let edge_key = format!(
192            "{}:validates:{}",
193            test.node.id.as_str(),
194            provider.node.id.as_str()
195        );
196        edges.push(Edge {
197            id: EdgeId::new(stable_id("edge", &edge_key)),
198            source: test.node.id.clone(),
199            target: provider.node.id.clone(),
200            kind: EdgeKind::Validates,
201            confidence: test.evidence.confidence.min(provider.evidence.confidence),
202            status: consensus_status(test.evidence.confidence.min(provider.evidence.confidence)),
203            evidence: vec![test.evidence.id.clone(), provider.evidence.id.clone()],
204        });
205    }
206    edges.sort_by(|left, right| left.id.cmp(&right.id));
207    sort_http_ambiguities(&mut ambiguities);
208    HttpLinkResolution { edges, ambiguities }
209}
210
211/// Links HTTP provider contracts to declared source implementations.
212///
213/// Duplicate providers remain fail-closed for compatibility. Use
214/// [`link_declared_implementations_with_ambiguities`] to preserve ambiguities as data while
215/// continuing with unrelated contracts.
216///
217/// # Errors
218///
219/// Returns [`LinkError::AmbiguousProvider`] instead of silently omitting an ambiguous relationship.
220pub fn link_declared_implementations(
221    implementations: &[DeclaredImplementation],
222    boundaries: &[HttpBoundary],
223) -> Result<Vec<Edge>, LinkError> {
224    link_declared_implementations_with_ambiguities(implementations, boundaries).into_legacy_result()
225}
226
227/// Links declared implementations while preserving duplicate-provider decisions.
228#[must_use]
229pub fn link_declared_implementations_with_ambiguities(
230    implementations: &[DeclaredImplementation],
231    boundaries: &[HttpBoundary],
232) -> HttpLinkResolution {
233    let mut edges = Vec::new();
234    let mut ambiguities = Vec::new();
235    for implementation in implementations {
236        let candidates = boundaries
237            .iter()
238            .filter(|boundary| {
239                boundary.role == BoundaryRole::Provider
240                    && boundary.node.repo_id == implementation.node.repo_id
241                    && boundary.method == implementation.method
242                    && boundary.path == implementation.path
243            })
244            .fold(
245                BTreeMap::<NodeId, &HttpBoundary>::new(),
246                |mut candidates, boundary| {
247                    candidates
248                        .entry(boundary.node.id.clone())
249                        .and_modify(|existing| {
250                            if boundary.evidence.confidence > existing.evidence.confidence {
251                                *existing = boundary;
252                            }
253                        })
254                        .or_insert(boundary);
255                    candidates
256                },
257            )
258            .into_values()
259            .collect::<Vec<_>>();
260        if candidates.len() > 1 {
261            ambiguities.push(http_link_ambiguity(
262                &implementation.method,
263                &implementation.path,
264                candidates.iter().map(|candidate| &candidate.node.id),
265            ));
266            continue;
267        }
268        let Some(provider) = candidates.first() else {
269            continue;
270        };
271        let edge_key = format!(
272            "{}:implemented_by:{}",
273            provider.node.id.as_str(),
274            implementation.node.id.as_str()
275        );
276        let confidence = provider
277            .evidence
278            .confidence
279            .min(implementation.evidence.confidence);
280        edges.push(Edge {
281            id: EdgeId::new(stable_id("edge", &edge_key)),
282            source: provider.node.id.clone(),
283            target: implementation.node.id.clone(),
284            kind: EdgeKind::ImplementedBy,
285            confidence,
286            status: consensus_status(confidence),
287            evidence: vec![
288                provider.evidence.id.clone(),
289                implementation.evidence.id.clone(),
290            ],
291        });
292    }
293    edges.sort_by(|left, right| left.id.cmp(&right.id));
294    sort_http_ambiguities(&mut ambiguities);
295    HttpLinkResolution { edges, ambiguities }
296}
297
298fn consensus_status(confidence: f32) -> EpistemicStatus {
299    if confidence >= 1.0 {
300        EpistemicStatus::Confirmed
301    } else {
302        EpistemicStatus::Inferred
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use code_system_graph_model::{EdgeKind, EpistemicStatus, RepoId};
309
310    use super::{declared_test_case, link_declared_implementations, link_declared_tests};
311    use crate::{
312        HttpContractConfig, IntegrationTestConfig, extract_openapi, parse_rust_source, source_observations_to_graph
313    };
314
315    #[test]
316    fn declared_python_test_should_validate_rust_provider_with_bilateral_evidence() {
317        let test = declared_test_case(
318            RepoId::new("repo:tests"),
319            &IntegrationTestConfig {
320                name: "test_create_order".to_owned(),
321                path: "tests/test_orders.py".to_owned(),
322                framework: "pytest".to_owned(),
323                language: "python".to_owned(),
324                validates: HttpContractConfig {
325                    method: "POST".to_owned(),
326                    path: "/api/orders".to_owned(),
327                },
328            },
329            Some("content:test".to_owned()),
330        );
331        let providers = extract_openapi(
332            &RepoId::new("repo:rust-api"),
333            "openapi.yaml",
334            "openapi: 3.1.0\npaths:\n  /api/orders:\n    post: {}\n",
335        );
336        let result = providers
337            .map_err(|error| error.to_string())
338            .and_then(|providers| {
339                link_declared_tests(&[test], &providers).map_err(|error| error.to_string())
340            });
341
342        assert!(matches!(
343            result,
344            Ok(edges)
345                if edges.len() == 1
346                    && edges[0].kind == EdgeKind::Validates
347                    && edges[0].evidence.len() == 2
348        ));
349    }
350
351    #[test]
352    fn executable_route_should_outweigh_divergent_utoipa_advisory() {
353        let repo = RepoId::new("repo:rust-api");
354        let observations = parse_rust_source(
355            r#"
356use actix_web::get;
357
358#[utoipa::path(get, path = "/documented")]
359#[get("/runtime")]
360async fn handler() {}
361"#,
362        );
363        let facts =
364            source_observations_to_graph(&repo, "src/routes.rs", "content:routes", &observations);
365        let edges = link_declared_implementations(&facts.implementations, &facts.boundaries)
366            .expect("both exact declarations should remain linkable");
367        let mut consensus = edges
368            .iter()
369            .filter_map(|edge| {
370                let path = facts
371                    .boundaries
372                    .iter()
373                    .find(|boundary| boundary.node.id == edge.source)?
374                    .path
375                    .clone();
376                Some((path, edge.confidence, edge.status))
377            })
378            .collect::<Vec<_>>();
379        consensus.sort_by(|left, right| left.0.cmp(&right.0));
380
381        assert_eq!(
382            consensus,
383            vec![
384                ("/documented".to_owned(), 0.75, EpistemicStatus::Inferred),
385                ("/runtime".to_owned(), 1.0, EpistemicStatus::Confirmed),
386            ]
387        );
388    }
389}