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#[derive(Debug, Clone, Default)]
13pub struct SourceGraphFacts {
14 pub boundaries: Vec<HttpBoundary>,
16 pub tests: Vec<DeclaredTestCase>,
18 pub implementations: Vec<DeclaredImplementation>,
20 pub standalone_test_nodes: Vec<Node>,
22 pub standalone_test_evidence: Vec<Evidence>,
24 pub relation_nodes: Vec<Node>,
26 pub relation_edges: Vec<Edge>,
28 pub relation_evidence: Vec<Evidence>,
30}
31
32#[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}