Skip to main content

code_system_graph_core/
source_graph.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use code_system_graph_model::{
4    Edge, EdgeId, EdgeKind, EpistemicStatus, Evidence, EvidenceId, Node, NodeId, NodeKind, Provenance, RepoId, stable_id
5};
6
7use crate::{
8    BoundaryRole, DeclaredImplementation, DeclaredTestCase, HttpBoundary, SourceFramework, SourceLanguage, SourceObservation, SourceRole
9};
10
11/// Graph-ready facts derived from one focused Rust or Python source file.
12#[derive(Debug, Clone, Default)]
13pub struct SourceGraphFacts {
14    /// Exact HTTP provider and consumer boundaries.
15    pub boundaries: Vec<HttpBoundary>,
16    /// Tests paired with exact HTTP calls in the same test symbol.
17    pub tests: Vec<DeclaredTestCase>,
18    /// Exact provider implementation symbols.
19    pub implementations: Vec<DeclaredImplementation>,
20    /// Test nodes that have no exact HTTP target in the same symbol.
21    pub standalone_test_nodes: Vec<Node>,
22    /// Evidence for standalone test nodes.
23    pub standalone_test_evidence: Vec<Evidence>,
24    /// Factory and statically imported model symbols.
25    pub relation_nodes: Vec<Node>,
26    /// Direct source-level relationships such as Factory Boy `Meta.model`.
27    pub relation_edges: Vec<Edge>,
28    /// Evidence for direct source-level relationships.
29    pub relation_evidence: Vec<Evidence>,
30}
31
32/// Converts exact focused source observations into graph-ready facts.
33///
34/// Ambiguous and incomplete observations remain in the persisted extractor payload but are not
35/// promoted to factual graph nodes or links.
36#[must_use]
37#[expect(
38    clippy::too_many_lines,
39    reason = "One deterministic conversion pass keeps HTTP, test, and source relations aligned"
40)]
41pub fn source_observations_to_graph(
42    repo_id: &RepoId,
43    source_path: &str,
44    content_hash: &str,
45    observations: &[SourceObservation],
46) -> SourceGraphFacts {
47    let mut result = SourceGraphFacts::default();
48    let confirmed = observations
49        .iter()
50        .filter(|observation| observation.status == crate::SourceEpistemicStatus::Confirmed)
51        .collect::<Vec<_>>();
52    for observation in &confirmed {
53        match observation.role {
54            SourceRole::Provider | SourceRole::Consumer => {
55                let (Some(method), Some(path)) =
56                    (observation.method.as_deref(), observation.path.as_deref())
57                else {
58                    continue;
59                };
60                let boundary = source_boundary(
61                    repo_id,
62                    source_path,
63                    content_hash,
64                    observation,
65                    method,
66                    path,
67                );
68                if observation.role == SourceRole::Provider
69                    && let Some(symbol) = observation.symbol_name.as_deref()
70                {
71                    result.implementations.push(source_implementation(
72                        repo_id,
73                        source_path,
74                        content_hash,
75                        observation,
76                        symbol,
77                        method,
78                        path,
79                    ));
80                }
81                result.boundaries.push(boundary);
82            }
83            SourceRole::Factory => {
84                let (Some(symbol), Some(related_symbol)) = (
85                    observation.symbol_name.as_deref(),
86                    observation.related_symbol.as_deref(),
87                ) else {
88                    continue;
89                };
90                let (nodes, edge, evidence) = source_factory_relation(
91                    repo_id,
92                    source_path,
93                    content_hash,
94                    observation,
95                    symbol,
96                    related_symbol,
97                );
98                result.relation_nodes.extend(nodes);
99                result.relation_edges.push(edge);
100                result.relation_evidence.push(evidence);
101            }
102            SourceRole::Test => {}
103        }
104    }
105
106    let consumers_by_symbol = confirmed
107        .iter()
108        .filter(|observation| observation.role == SourceRole::Consumer)
109        .filter_map(|observation| {
110            Some((
111                observation.symbol_name.as_deref()?,
112                observation.method.as_deref()?,
113                observation.path.as_deref()?,
114            ))
115        })
116        .fold(
117            BTreeMap::<&str, BTreeSet<(&str, &str)>>::new(),
118            |mut grouped, (symbol, method, path)| {
119                grouped.entry(symbol).or_default().insert((method, path));
120                grouped
121            },
122        );
123    for observation in confirmed
124        .iter()
125        .filter(|observation| observation.role == SourceRole::Test)
126    {
127        let Some(symbol) = observation.symbol_name.as_deref() else {
128            continue;
129        };
130        if let Some(targets) = consumers_by_symbol.get(symbol) {
131            result.tests.extend(targets.iter().map(|(method, path)| {
132                source_test_case(
133                    repo_id,
134                    source_path,
135                    content_hash,
136                    observation,
137                    symbol,
138                    method,
139                    path,
140                )
141            }));
142        } else {
143            let (node, evidence) =
144                standalone_test(repo_id, source_path, content_hash, observation, symbol);
145            result.standalone_test_nodes.push(node);
146            result.standalone_test_evidence.push(evidence);
147        }
148    }
149    finish_source_graph(&mut result);
150    result
151}
152
153fn finish_source_graph(result: &mut SourceGraphFacts) {
154    result.boundaries.sort_by(|left, right| {
155        (&left.path, &left.method, left.role as u8, &left.node.id).cmp(&(
156            &right.path,
157            &right.method,
158            right.role as u8,
159            &right.node.id,
160        ))
161    });
162    result
163        .implementations
164        .sort_by(|left, right| left.node.id.cmp(&right.node.id));
165    result.tests.sort_by(|left, right| {
166        (&left.node.id, &left.method, &left.path).cmp(&(&right.node.id, &right.method, &right.path))
167    });
168    result
169        .standalone_test_nodes
170        .sort_by(|left, right| left.id.cmp(&right.id));
171    result
172        .standalone_test_evidence
173        .sort_by(|left, right| left.id.cmp(&right.id));
174    result
175        .relation_nodes
176        .sort_by(|left, right| left.id.cmp(&right.id));
177    result
178        .relation_nodes
179        .dedup_by(|left, right| left.id == right.id);
180    result
181        .relation_edges
182        .sort_by(|left, right| left.id.cmp(&right.id));
183    result
184        .relation_edges
185        .dedup_by(|left, right| left.id == right.id);
186    result
187        .relation_evidence
188        .sort_by(|left, right| left.id.cmp(&right.id));
189    result
190        .relation_evidence
191        .dedup_by(|left, right| left.id == right.id);
192}
193
194fn source_boundary(
195    repo_id: &RepoId,
196    source_path: &str,
197    content_hash: &str,
198    observation: &SourceObservation,
199    method: &str,
200    path: &str,
201) -> HttpBoundary {
202    let role = match observation.role {
203        SourceRole::Provider => BoundaryRole::Provider,
204        SourceRole::Consumer => BoundaryRole::Consumer,
205        SourceRole::Test | SourceRole::Factory => {
206            unreachable!("test and factory observations are not HTTP boundaries")
207        }
208    };
209    let role_key = match role {
210        BoundaryRole::Provider => "provider",
211        BoundaryRole::Consumer => "consumer",
212    };
213    let stable_key = format!("http:{}:{role_key}:{method}:{path}", repo_id.as_str());
214    let evidence = source_evidence(
215        repo_id,
216        source_path,
217        content_hash,
218        observation,
219        &format!("{stable_key}:{}", observation.lines.start),
220    );
221    HttpBoundary {
222        node: Node {
223            id: NodeId::new(stable_id("node", &stable_key)),
224            kind: NodeKind::HttpOperation,
225            repo_id: Some(repo_id.clone()),
226            stable_key,
227            label: format!("{method} {path}"),
228        },
229        method: method.to_owned(),
230        path: path.to_owned(),
231        role,
232        evidence,
233    }
234}
235
236fn source_implementation(
237    repo_id: &RepoId,
238    source_path: &str,
239    content_hash: &str,
240    observation: &SourceObservation,
241    symbol: &str,
242    method: &str,
243    path: &str,
244) -> DeclaredImplementation {
245    let stable_key = format!(
246        "symbol:{}:{}:{source_path}:{symbol}",
247        repo_id.as_str(),
248        language_name(observation.language)
249    );
250    DeclaredImplementation {
251        node: Node {
252            id: NodeId::new(stable_id("node", &stable_key)),
253            kind: NodeKind::SymbolRef,
254            repo_id: Some(repo_id.clone()),
255            stable_key: stable_key.clone(),
256            label: format!("{}::{symbol}", language_name(observation.language)),
257        },
258        method: method.to_owned(),
259        path: path.to_owned(),
260        evidence: source_evidence(repo_id, source_path, content_hash, observation, &stable_key),
261    }
262}
263
264fn source_test_case(
265    repo_id: &RepoId,
266    source_path: &str,
267    content_hash: &str,
268    observation: &SourceObservation,
269    symbol: &str,
270    method: &str,
271    path: &str,
272) -> DeclaredTestCase {
273    let stable_key = test_stable_key(repo_id, source_path, observation, symbol);
274    DeclaredTestCase {
275        node: Node {
276            id: NodeId::new(stable_id("node", &stable_key)),
277            kind: NodeKind::TestCase,
278            repo_id: Some(repo_id.clone()),
279            stable_key: stable_key.clone(),
280            label: format!(
281                "{}/{}::{symbol}",
282                language_name(observation.language),
283                framework_name(observation.framework)
284            ),
285        },
286        method: method.to_owned(),
287        path: path.to_owned(),
288        evidence: source_evidence(
289            repo_id,
290            source_path,
291            content_hash,
292            observation,
293            &format!("{stable_key}:{method}:{path}"),
294        ),
295    }
296}
297
298fn standalone_test(
299    repo_id: &RepoId,
300    source_path: &str,
301    content_hash: &str,
302    observation: &SourceObservation,
303    symbol: &str,
304) -> (Node, Evidence) {
305    let stable_key = test_stable_key(repo_id, source_path, observation, symbol);
306    (
307        Node {
308            id: NodeId::new(stable_id("node", &stable_key)),
309            kind: NodeKind::TestCase,
310            repo_id: Some(repo_id.clone()),
311            stable_key: stable_key.clone(),
312            label: format!(
313                "{}/{}::{symbol}",
314                language_name(observation.language),
315                framework_name(observation.framework)
316            ),
317        },
318        source_evidence(repo_id, source_path, content_hash, observation, &stable_key),
319    )
320}
321
322fn source_factory_relation(
323    repo_id: &RepoId,
324    source_path: &str,
325    content_hash: &str,
326    observation: &SourceObservation,
327    factory_symbol: &str,
328    model_symbol: &str,
329) -> ([Node; 2], Edge, Evidence) {
330    let factory_key = format!(
331        "symbol:{}:python:{source_path}:{factory_symbol}",
332        repo_id.as_str()
333    );
334    let model_path = observation.related_path.as_deref().unwrap_or(source_path);
335    let model_key = format!(
336        "symbol:{}:python:{model_path}:{model_symbol}",
337        repo_id.as_str()
338    );
339    let factory_node = Node {
340        id: NodeId::new(stable_id("node", &factory_key)),
341        kind: NodeKind::SymbolRef,
342        repo_id: Some(repo_id.clone()),
343        stable_key: factory_key,
344        label: format!("python/factory-boy::{factory_symbol}"),
345    };
346    let model_node = Node {
347        id: NodeId::new(stable_id("node", &model_key)),
348        kind: NodeKind::SymbolRef,
349        repo_id: Some(repo_id.clone()),
350        stable_key: model_key,
351        label: format!("python::{model_symbol}"),
352    };
353    let relation_key = format!(
354        "factory-model:{}:{}",
355        factory_node.id.as_str(),
356        model_node.id.as_str()
357    );
358    let evidence = source_evidence(
359        repo_id,
360        source_path,
361        content_hash,
362        observation,
363        &relation_key,
364    );
365    let edge = Edge {
366        id: EdgeId::new(stable_id("edge", &relation_key)),
367        source: factory_node.id.clone(),
368        target: model_node.id.clone(),
369        kind: EdgeKind::Consumes,
370        confidence: observation.confidence,
371        status: EpistemicStatus::Confirmed,
372        evidence: vec![evidence.id.clone()],
373    };
374    ([factory_node, model_node], edge, evidence)
375}
376
377fn test_stable_key(
378    repo_id: &RepoId,
379    source_path: &str,
380    observation: &SourceObservation,
381    symbol: &str,
382) -> String {
383    format!(
384        "test:{}:{}:{}:{source_path}:{symbol}",
385        repo_id.as_str(),
386        language_name(observation.language),
387        framework_name(observation.framework)
388    )
389}
390
391fn source_evidence(
392    repo_id: &RepoId,
393    source_path: &str,
394    content_hash: &str,
395    observation: &SourceObservation,
396    evidence_key: &str,
397) -> Evidence {
398    Evidence {
399        id: EvidenceId::new(stable_id("evidence", evidence_key)),
400        repo_id: Some(repo_id.clone()),
401        file_path: Some(source_path.to_owned()),
402        start_line: Some(observation.lines.start),
403        end_line: Some(observation.lines.end),
404        extractor: format!(
405            "code-system-graph.source.{}",
406            language_name(observation.language)
407        ),
408        extractor_version: "1.0.0".to_owned(),
409        provenance: Provenance::Extracted,
410        confidence: observation.confidence,
411        observed_at_commit: None,
412        content_hash: Some(content_hash.to_owned()),
413        note: Some(format!(
414            "{} {} syntax",
415            framework_name(observation.framework),
416            role_name(observation.role)
417        )),
418    }
419}
420
421fn language_name(language: SourceLanguage) -> &'static str {
422    match language {
423        SourceLanguage::JavaScript => "javascript",
424        SourceLanguage::TypeScript => "typescript",
425        SourceLanguage::Rust => "rust",
426        SourceLanguage::Python => "python",
427        SourceLanguage::Go => "go",
428        SourceLanguage::Java => "java",
429    }
430}
431
432fn framework_name(framework: SourceFramework) -> &'static str {
433    match framework {
434        SourceFramework::Fetch => "fetch",
435        SourceFramework::Axios => "axios",
436        SourceFramework::Express => "express",
437        SourceFramework::Fastify => "fastify",
438        SourceFramework::NestJs => "nestjs",
439        SourceFramework::NextJs => "nextjs",
440        SourceFramework::Axum => "axum",
441        SourceFramework::ActixWeb => "actix-web",
442        SourceFramework::Utoipa => "utoipa",
443        SourceFramework::Reqwest => "reqwest",
444        SourceFramework::RustTest => "rust-test",
445        SourceFramework::TokioTest => "tokio-test",
446        SourceFramework::Rstest => "rstest",
447        SourceFramework::FastApi => "fastapi",
448        SourceFramework::Flask => "flask",
449        SourceFramework::Requests => "requests",
450        SourceFramework::Httpx => "httpx",
451        SourceFramework::AioHttp => "aiohttp",
452        SourceFramework::PythonHttpRegistry => "python-http-registry",
453        SourceFramework::FactoryBoy => "factory-boy",
454        SourceFramework::Pytest => "pytest",
455        SourceFramework::Unittest => "unittest",
456        SourceFramework::PythonTest => "python-test",
457        SourceFramework::GoNetHttp => "net-http",
458        SourceFramework::Gin => "gin",
459        SourceFramework::Chi => "chi",
460        SourceFramework::SpringMvc => "spring-mvc",
461        SourceFramework::WebClient => "webclient",
462        SourceFramework::Feign => "feign",
463    }
464}
465
466fn role_name(role: SourceRole) -> &'static str {
467    match role {
468        SourceRole::Provider => "provider",
469        SourceRole::Consumer => "consumer",
470        SourceRole::Test => "test",
471        SourceRole::Factory => "factory",
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use code_system_graph_model::{EdgeKind, RepoId};
478
479    use super::source_observations_to_graph;
480    use crate::{
481        link_declared_implementations, link_declared_tests, parse_python_source, parse_rust_source
482    };
483
484    #[test]
485    fn source_graph_should_link_python_test_to_rust_handler() {
486        let rust = source_observations_to_graph(
487            &RepoId::new("repo:api"),
488            "src/routes.rs",
489            "rust-hash",
490            &parse_rust_source(
491                r#"use axum::{Router, routing::post}; Router::new().route("/orders", post(create_order));"#,
492            ),
493        );
494        let python = source_observations_to_graph(
495            &RepoId::new("repo:tests"),
496            "tests/test_orders.py",
497            "python-hash",
498            &parse_python_source(
499                r#"import requests
500def test_create_order():
501    requests.post("https://api.test/orders")
502"#,
503            ),
504        );
505
506        let test_edges = link_declared_tests(&python.tests, &rust.boundaries)
507            .unwrap_or_else(|error| panic!("fixtures must link: {error}"));
508        let implementation_edges =
509            link_declared_implementations(&rust.implementations, &rust.boundaries)
510                .unwrap_or_else(|error| panic!("fixtures must link: {error}"));
511
512        assert!(
513            test_edges
514                .iter()
515                .all(|edge| edge.kind == EdgeKind::Validates)
516        );
517        assert!(
518            implementation_edges
519                .iter()
520                .all(|edge| edge.kind == EdgeKind::ImplementedBy)
521        );
522    }
523
524    #[test]
525    fn source_graph_should_link_factory_boy_factory_to_model() {
526        let facts = source_observations_to_graph(
527            &RepoId::new("repo:tests"),
528            "src/factories/AccountFactory.py",
529            "python-hash",
530            &parse_python_source(
531                r"import factory
532from src.clases.Account import Account
533
534class AccountFactory(factory.Factory):
535    class Meta:
536        model = Account
537",
538            ),
539        );
540
541        assert!(
542            facts.relation_nodes.len() == 2
543                && facts.relation_edges.len() == 1
544                && facts.relation_edges[0].kind == EdgeKind::Consumes
545                && facts
546                    .relation_nodes
547                    .iter()
548                    .any(|node| node.label == "python/factory-boy::AccountFactory")
549                && facts
550                    .relation_nodes
551                    .iter()
552                    .any(|node| node.stable_key.ends_with("src/clases/Account.py:Account"))
553        );
554    }
555}