1use serde::Serialize;
8
9use crate::graph::CodeGraph;
10use crate::graph::edge::EdgeKind;
11use crate::graph::node::NodeData;
12use crate::graph::scc::{DeployabilityHint, SccAnalysis};
13
14pub const SCHEMA_VERSION: u32 = 2;
17
18#[derive(Debug, Clone, Serialize)]
23#[non_exhaustive]
24pub struct GraphOutput {
25 pub schema_version: u32,
27 pub metadata: GraphMetadata,
29 pub nodes: Vec<SerializedNode>,
31 pub edges: Vec<SerializedEdge>,
33 pub sccs: Vec<SerializedScc>,
35 pub deployability: Option<DeployabilityStats>,
37}
38
39#[derive(Debug, Clone, Serialize)]
41#[non_exhaustive]
42pub struct GraphMetadata {
43 pub snapshot_id: u64,
45 pub node_count: usize,
47 pub edge_count: usize,
49 pub scc_count: usize,
51 pub file_count: usize,
53 pub symbol_count: usize,
55 pub data_node_count: usize,
57 pub invalid_confidence_edges: usize,
59}
60
61#[derive(Debug, Clone, Serialize)]
63#[non_exhaustive]
64pub struct SerializedNode {
65 pub id: usize,
67 pub kind: String,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub path: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub file_path: Option<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub source_range: Option<crate::model::SourceRange>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub language: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub name: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub symbol_kind: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub visibility: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub data_scope: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub type_hint: Option<String>,
96}
97
98#[derive(Debug, Clone, Serialize)]
100#[non_exhaustive]
101pub struct SerializedEdge {
102 pub source: usize,
104 pub target: usize,
106 pub kind: String,
108 pub confidence: f32,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub flow_kind: Option<String>,
117}
118
119#[derive(Debug, Clone, Serialize)]
121#[non_exhaustive]
122pub struct SerializedScc {
123 pub index: usize,
125 pub nodes: Vec<usize>,
127 pub is_cyclic: bool,
129 pub hint: String,
131 pub size: usize,
133}
134
135#[derive(Debug, Clone, Serialize)]
137#[non_exhaustive]
138pub struct DeployabilityStats {
139 pub cyclic_clusters: usize,
141 pub independent_units: usize,
143 pub self_loops: usize,
145 pub total_components: usize,
147}
148
149impl GraphOutput {
150 pub fn from_graph(
155 graph: &CodeGraph,
156 scc_analysis: Option<&SccAnalysis>,
157 snapshot_id: u64,
158 ) -> Self {
159 let metadata = Self::build_metadata(graph, scc_analysis, snapshot_id);
160 let nodes = Self::serialize_nodes(graph);
161 let edges = Self::serialize_edges(graph);
162
163 let (sccs, deployability) = if let Some(scc) = scc_analysis {
164 (
165 Self::serialize_sccs(scc),
166 Some(Self::build_deployability_stats(scc)),
167 )
168 } else {
169 (Vec::new(), None)
170 };
171
172 Self {
173 schema_version: SCHEMA_VERSION,
174 metadata,
175 nodes,
176 edges,
177 sccs,
178 deployability,
179 }
180 }
181
182 fn build_metadata(
183 graph: &CodeGraph,
184 scc_analysis: Option<&SccAnalysis>,
185 snapshot_id: u64,
186 ) -> GraphMetadata {
187 let g = graph.graph();
188 let node_count = g.node_count();
189 let edge_count = g.edge_count();
190 let scc_count = scc_analysis.map_or(0, |s| s.components.len());
191
192 let mut file_count = 0;
193 let mut symbol_count = 0;
194 let mut data_node_count = 0;
195 let mut invalid_confidence_edges = 0;
196
197 for node_data in g.node_weights() {
198 match node_data {
199 NodeData::File(_) => file_count += 1,
200 NodeData::Symbol(_) => symbol_count += 1,
201 NodeData::External(_) => {}
202 NodeData::Data(_) => data_node_count += 1,
203 }
204 }
205 for edge_data in g.edge_weights() {
206 if !edge_data.confidence.is_finite() {
207 invalid_confidence_edges += 1;
208 }
209 }
210
211 GraphMetadata {
212 snapshot_id,
213 node_count,
214 edge_count,
215 scc_count,
216 file_count,
217 symbol_count,
218 data_node_count,
219 invalid_confidence_edges,
220 }
221 }
222
223 fn serialize_nodes(graph: &CodeGraph) -> Vec<SerializedNode> {
224 let g = graph.graph();
225 let mut nodes: Vec<SerializedNode> = g
226 .node_indices()
227 .map(|idx| {
228 let node_data = &g[idx];
229 Self::serialize_node(graph, idx.index(), node_data)
230 })
231 .collect();
232 nodes.sort_by(|left, right| node_sort_key(left).cmp(&node_sort_key(right)));
233 nodes
234 }
235
236 fn serialize_node(graph: &CodeGraph, id: usize, node_data: &NodeData) -> SerializedNode {
237 let mut node = SerializedNode {
238 id,
239 kind: crate::graph::naming::node_kind_name(node_data).to_string(),
240 path: None,
241 file_path: None,
242 source_range: None,
243 language: crate::graph::naming::node_language(node_data)
244 .map(|language| language.as_ref().to_string()),
245 name: crate::graph::naming::node_display_name(node_data).map(str::to_string),
246 symbol_kind: None,
247 visibility: None,
248 data_scope: None,
249 type_hint: None,
250 };
251
252 match node_data {
253 NodeData::File(file) => {
254 node.path = Some(crate::input::portable_path(&file.path));
255 }
256 NodeData::Symbol(symbol) => {
257 node.file_path = graph
258 .file_node(symbol.file_id)
259 .map(|file| crate::input::portable_path(&file.path));
260 node.source_range = Some(symbol.source_range.clone());
261 node.symbol_kind = Some(symbol.kind.as_str().to_string());
262 node.visibility = symbol
263 .visibility
264 .map(|visibility| visibility.as_str().to_string());
265 }
266 NodeData::External(external) => {
267 node.path = Some(external.raw_path.clone());
268 }
269 NodeData::Data(data) => {
270 node.data_scope = Some(data.scope.as_str().to_string());
271 node.type_hint = data.type_hint.clone();
272 }
273 }
274
275 node
276 }
277
278 fn serialize_edges(graph: &CodeGraph) -> Vec<SerializedEdge> {
279 let g = graph.graph();
280 let mut edges: Vec<SerializedEdge> = g
281 .edge_indices()
282 .filter_map(|edge_idx| {
283 let (source, target) = g.edge_endpoints(edge_idx)?;
284 let edge_data = g.edge_weight(edge_idx)?;
285 let confidence = if edge_data.confidence.is_finite() {
288 edge_data.confidence.clamp(0.0, 1.0)
289 } else {
290 0.0
291 };
292
293 let flow_kind = if edge_data.kind == EdgeKind::Flow {
294 edge_data.flow_kind.map(|fk| fk.as_str().to_string())
295 } else {
296 None
297 };
298
299 Some(SerializedEdge {
300 source: source.index(),
301 target: target.index(),
302 kind: edge_data.kind.as_str().to_string(),
303 confidence,
304 flow_kind,
305 })
306 })
307 .collect();
308 edges.sort_by(|a, b| (a.source, a.target, &a.kind).cmp(&(b.source, b.target, &b.kind)));
309 edges
310 }
311
312 fn serialize_sccs(scc_analysis: &SccAnalysis) -> Vec<SerializedScc> {
313 scc_analysis
314 .components
315 .iter()
316 .map(|scc| {
317 let mut nodes: Vec<usize> = scc.nodes.iter().map(|n| n.index()).collect();
318 nodes.sort_unstable();
319
320 SerializedScc {
321 index: scc.index,
322 nodes,
323 is_cyclic: scc.is_cyclic,
324 hint: scc.hint.to_string(),
325 size: scc.nodes.len(),
326 }
327 })
328 .collect()
329 }
330
331 fn build_deployability_stats(scc_analysis: &SccAnalysis) -> DeployabilityStats {
332 let mut cyclic_clusters = 0;
333 let mut independent_units = 0;
334 let mut self_loops = 0;
335
336 for scc in &scc_analysis.components {
337 match scc.hint {
338 DeployabilityHint::Independent | DeployabilityHint::AcyclicDependency => {
339 independent_units += 1;
340 }
341 DeployabilityHint::CyclicCluster => {
342 cyclic_clusters += 1;
343 }
344 DeployabilityHint::SelfLoop => {
345 self_loops += 1;
346 }
347 }
348 }
349
350 DeployabilityStats {
351 cyclic_clusters,
352 independent_units,
353 self_loops,
354 total_components: scc_analysis.components.len(),
355 }
356 }
357}
358
359pub fn serialize_graph(
361 graph: &CodeGraph,
362 scc_analysis: &SccAnalysis,
363 snapshot_id: u64,
364 format: &crate::output::OutputFormat,
365) -> anyhow::Result<String> {
366 let output = GraphOutput::from_graph(graph, Some(scc_analysis), snapshot_id);
367 format.serialize(&output)
368}
369
370fn node_sort_key(node: &SerializedNode) -> (u8, &str, &str, usize) {
376 let rank = match node.kind.as_str() {
377 "file" => 0,
378 "symbol" => 1,
379 "external" => 2,
380 _ => 3,
381 };
382 (
383 rank,
384 node.path
385 .as_deref()
386 .or(node.file_path.as_deref())
387 .unwrap_or(""),
388 node.name.as_deref().unwrap_or(""),
389 node.id,
390 )
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use crate::graph::GraphBuilder;
397 use crate::graph::node::{FileNode, SymbolNode};
398 use crate::language::LangId;
399 use crate::model::{DataNodeId, DataScope, FlowKind, LineColumn, SnapshotId, SourceRange};
400 use crate::output::OutputFormat;
401 use std::path::PathBuf;
402
403 fn sample_source_range() -> SourceRange {
404 SourceRange {
405 byte_start: 0,
406 byte_end: 10,
407 start: LineColumn { line: 0, column: 0 },
408 end: LineColumn {
409 line: 0,
410 column: 10,
411 },
412 }
413 }
414
415 fn sample_scc_analysis() -> SccAnalysis {
416 SccAnalysis {
417 components: Vec::new(),
418 node_to_component: std::collections::HashMap::new(),
419 }
420 }
421
422 #[test]
425 fn graph_output_with_file_node() {
426 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
427 builder.add_file(PathBuf::from("src/main.py"), LangId::Python);
428 let graph = builder.build();
429 let scc = sample_scc_analysis();
430
431 let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
432
433 assert_eq!(output.schema_version, SCHEMA_VERSION);
434 assert_eq!(output.metadata.file_count, 1);
435 assert_eq!(output.metadata.symbol_count, 0);
436 assert_eq!(output.metadata.data_node_count, 0);
437 assert_eq!(output.metadata.scc_count, 0);
438 assert_eq!(output.nodes.len(), 1);
439 assert_eq!(output.nodes[0].kind, "file");
440 assert_eq!(output.nodes[0].path.as_deref(), Some("src/main.py"));
441 assert_eq!(output.nodes[0].language.as_deref(), Some("python"));
442 }
443
444 #[test]
445 fn graph_output_language_uses_canonical_names() {
446 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
447 builder.add_file(PathBuf::from("src/main.js"), LangId::JavaScript);
448 builder.add_file(PathBuf::from("src/main.ts"), LangId::TypeScript);
449 builder.add_file(PathBuf::from("src/main.tsx"), LangId::Tsx);
450 let graph = builder.build();
451 let scc = sample_scc_analysis();
452
453 let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
454
455 let language_of = |path: &str| {
456 output
457 .nodes
458 .iter()
459 .find(|n| n.path.as_deref() == Some(path))
460 .and_then(|n| n.language.as_deref())
461 };
462 assert_eq!(language_of("src/main.js"), Some("javascript"));
463 assert_eq!(language_of("src/main.ts"), Some("typescript"));
464 assert_eq!(language_of("src/main.tsx"), Some("tsx"));
465 }
466
467 #[test]
468 fn json_output_has_required_structure() {
469 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
470 builder.add_file(PathBuf::from("src/main.py"), LangId::Python);
471 let graph = builder.build();
472 let scc = sample_scc_analysis();
473
474 let json = serialize_graph(&graph, &scc, 1, &OutputFormat::Json).unwrap();
475 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
476
477 assert_eq!(parsed["schema_version"], 2);
478 assert!(parsed["metadata"].is_object());
479 assert!(parsed["nodes"].is_array());
480 assert!(parsed["edges"].is_array());
481 if let Some(sccs) = parsed.get("sccs") {
483 assert!(sccs.is_array());
484 }
485 }
486
487 #[test]
488 fn empty_graph_serializes() {
489 let builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
490 let graph = builder.build();
491 let scc = sample_scc_analysis();
492
493 let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
494 let json = serde_json::to_string(&output).unwrap();
495
496 assert!(json.contains("\"node_count\":0"));
497 assert!(json.contains("\"edge_count\":0"));
498 assert!(json.contains("\"schema_version\":2"));
499 }
500
501 #[test]
502 fn scc_fields_serialize() {
503 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
504 builder.add_file(PathBuf::from("a.py"), LangId::Python);
505 builder.add_file(PathBuf::from("b.py"), LangId::Python);
506 let graph = builder.build();
507 let scc = sample_scc_analysis();
508
509 let json = serialize_graph(&graph, &scc, 1, &OutputFormat::Json).unwrap();
510 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
511 let _sccs = &parsed["sccs"];
514 let _deploy = &parsed["deployability"];
516 }
517
518 #[test]
521 fn data_node_serializes_with_correct_fields() {
522 use crate::graph::node::DataGraphNode;
523 let mut graph = CodeGraph::new(SnapshotId::new(1).unwrap());
524 let dnode = DataGraphNode {
525 id: DataNodeId::new(10).unwrap(),
526 symbol_id: None,
527 name: Some("local_var".into()),
528 scope: DataScope::Local,
529 type_hint: Some("u32".into()),
530 source_range: sample_source_range(),
531 };
532 graph.add_node(NodeData::Data(dnode));
533 let scc = sample_scc_analysis();
534
535 let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
536 assert_eq!(output.metadata.data_node_count, 1);
537 assert_eq!(output.nodes[0].kind, "data");
538 assert_eq!(output.nodes[0].name.as_deref(), Some("local_var"));
539 assert_eq!(output.nodes[0].data_scope.as_deref(), Some("local"));
540 assert_eq!(output.nodes[0].type_hint.as_deref(), Some("u32"));
541 assert!(output.nodes[0].symbol_kind.is_none());
542 assert!(output.nodes[0].visibility.is_none());
543 }
544
545 #[test]
546 fn flow_edge_serializes_with_flow_kind() {
547 use crate::graph::node::DataGraphNode;
548 let mut graph = CodeGraph::new(SnapshotId::new(1).unwrap());
549 let src = graph.add_node(NodeData::Data(DataGraphNode {
550 id: DataNodeId::new(1).unwrap(),
551 symbol_id: None,
552 name: Some("x".into()),
553 scope: DataScope::Local,
554 type_hint: None,
555 source_range: sample_source_range(),
556 }));
557 let dst = graph.add_node(NodeData::Data(DataGraphNode {
558 id: DataNodeId::new(2).unwrap(),
559 symbol_id: None,
560 name: Some("y".into()),
561 scope: DataScope::Parameter,
562 type_hint: None,
563 source_range: sample_source_range(),
564 }));
565 graph.add_edge_normalized_with_flow(
566 src,
567 dst,
568 EdgeKind::Flow,
569 0.9,
570 Some(FlowKind::Argument),
571 );
572 let scc = sample_scc_analysis();
573
574 let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
575 assert_eq!(output.edges.len(), 1);
576 assert_eq!(output.edges[0].kind, "flow");
577 assert_eq!(output.edges[0].confidence, 0.9);
578 assert_eq!(output.edges[0].flow_kind.as_deref(), Some("argument"));
579 }
580
581 #[test]
582 fn flow_edge_without_flow_kind_omits_field() {
583 use crate::graph::node::DataGraphNode;
584 let mut graph = CodeGraph::new(SnapshotId::new(1).unwrap());
585 let src = graph.add_node(NodeData::Data(DataGraphNode {
586 id: DataNodeId::new(1).unwrap(),
587 symbol_id: None,
588 name: Some("a".into()),
589 scope: DataScope::Local,
590 type_hint: None,
591 source_range: sample_source_range(),
592 }));
593 let dst = graph.add_node(NodeData::Data(DataGraphNode {
594 id: DataNodeId::new(2).unwrap(),
595 symbol_id: None,
596 name: Some("b".into()),
597 scope: DataScope::Local,
598 type_hint: None,
599 source_range: sample_source_range(),
600 }));
601 graph.add_edge_normalized(src, dst, EdgeKind::Flow, 1.0);
602 let scc = sample_scc_analysis();
603
604 let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
605 assert_eq!(output.edges[0].flow_kind, None);
606 assert_eq!(output.edges[0].confidence, 1.0);
607 }
608
609 #[test]
612 fn light_mode_omits_scc_and_deployability() {
613 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
614 builder.add_file(PathBuf::from("main.rs"), LangId::Rust);
615 let graph = builder.build();
616
617 let output = GraphOutput::from_graph(&graph, None, 1);
618 let json = serde_json::to_string(&output).unwrap();
619 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
620
621 assert_eq!(output.metadata.scc_count, 0);
622 assert!(parsed.get("sccs").is_none() || parsed["sccs"].is_array());
624 assert!(parsed.get("deployability").is_none() || parsed["deployability"].is_null());
625 }
626
627 #[test]
628 fn light_mode_with_symbol_node() {
629 use crate::model::{Symbol, SymbolId, SymbolKind, Visibility};
630 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
631 builder.add_file(PathBuf::from("main.rs"), LangId::Rust);
632 let sym = Symbol {
633 id: SymbolId::new(1).unwrap(),
634 name: "main_fn".into(),
635 kind: SymbolKind::Function,
636 language: LangId::Rust,
637 file_path: PathBuf::from("main.rs"),
638 source_range: sample_source_range(),
639 name_range: None,
640 visibility: Some(Visibility::Public),
641 signature: None,
642 docstring: None,
643 is_async: false,
644 };
645 builder.add_symbol(&sym).unwrap();
646 let graph = builder.build();
647
648 let output = GraphOutput::from_graph(&graph, None, 1);
649 assert_eq!(output.nodes.len(), 2); assert_eq!(output.edges.len(), 1); let sym_node = output.nodes.iter().find(|n| n.kind == "symbol").unwrap();
652 assert_eq!(sym_node.name.as_deref(), Some("main_fn"));
653 assert_eq!(sym_node.file_path.as_deref(), Some("main.rs"));
654 assert_eq!(sym_node.source_range.as_ref(), Some(&sample_source_range()));
655 }
656
657 #[test]
658 fn has_all_required_keys() {
659 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
660 builder.add_file(PathBuf::from("main.rs"), LangId::Rust);
661 let graph = builder.build();
662
663 let output = GraphOutput::from_graph(&graph, None, 1);
664 let val: serde_json::Value = serde_json::to_value(&output).unwrap();
665 let required = ["schema_version", "metadata", "nodes", "edges"];
666 for key in &required {
667 assert!(val.get(key).is_some(), "missing required key: {key}");
668 }
669 }
670
671 #[test]
674 fn edge_kinds_are_valid() {
675 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
676 let file_a = builder.add_file(PathBuf::from("a.py"), LangId::Python);
677 let _file_b = builder.add_file(PathBuf::from("b.py"), LangId::Python);
678 builder.add_import(file_a, PathBuf::from("b.py"));
679 let graph = builder.build();
680 let scc = sample_scc_analysis();
681
682 let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
683 let valid_kinds = ["ownership", "import", "reference", "flow"];
684 for edge in &output.edges {
685 assert!(
686 valid_kinds.contains(&edge.kind.as_str()),
687 "unexpected edge kind: {}",
688 edge.kind
689 );
690 }
691 }
692
693 #[test]
694 fn serialized_edges_stay_sorted() {
695 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
696 builder.add_file(PathBuf::from("b.py"), LangId::Python);
697 builder.add_file(PathBuf::from("a.py"), LangId::Python);
698 let graph = builder.build();
699 let scc = sample_scc_analysis();
700
701 let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
702 let mut sorted = output.edges.clone();
703 sorted.sort_by(|a, b| (a.source, a.target, &a.kind).cmp(&(b.source, b.target, &b.kind)));
704 let order: Vec<_> = output
705 .edges
706 .iter()
707 .map(|e| (e.source, e.target, e.kind.clone()))
708 .collect();
709 let expected: Vec<_> = sorted
710 .iter()
711 .map(|e| (e.source, e.target, e.kind.clone()))
712 .collect();
713 assert_eq!(order, expected);
714 }
715
716 #[test]
718 fn serialized_nodes_use_canonical_order() {
719 let mut graph = crate::graph::CodeGraph::new(SnapshotId::new(1).unwrap());
720 let a_id = crate::model::FileId::new(1).unwrap();
721 let b_id = crate::model::FileId::new(2).unwrap();
722
723 let sym_idx = graph.add_node(NodeData::Symbol(SymbolNode {
725 id: crate::model::SymbolId::new(7).unwrap(),
726 name: "zeta".to_string(),
727 kind: crate::model::SymbolKind::Function,
728 file_id: b_id,
729 visibility: Some(crate::model::Visibility::Public),
730 source_range: sample_source_range(),
731 }));
732 let b_idx = graph.add_node(NodeData::File(FileNode::new(
733 b_id,
734 PathBuf::from("b.py"),
735 LangId::Python,
736 SnapshotId::new(1).unwrap(),
737 )));
738 let a_idx = graph.add_node(NodeData::File(FileNode::new(
739 a_id,
740 PathBuf::from("a.py"),
741 LangId::Python,
742 SnapshotId::new(1).unwrap(),
743 )));
744 graph.file_to_index.insert(a_id, a_idx);
745 graph.file_to_index.insert(b_id, b_idx);
746 graph.add_edge_normalized(a_idx, b_idx, EdgeKind::Import, 1.0);
747 graph.add_edge_normalized(b_idx, a_idx, EdgeKind::Import, 1.0);
748
749 let scc = SccAnalysis::analyze(graph.graph());
750 let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
751
752 let order: Vec<(String, Option<String>)> = output
753 .nodes
754 .iter()
755 .map(|n| (n.kind.clone(), n.path.clone().or_else(|| n.name.clone())))
756 .collect();
757 assert_eq!(
758 order,
759 vec![
760 ("file".to_string(), Some("a.py".to_string())),
761 ("file".to_string(), Some("b.py".to_string())),
762 ("symbol".to_string(), Some("zeta".to_string())),
763 ],
764 "files come first in path order, then symbols"
765 );
766
767 for scc in &output.sccs {
768 let members = &scc.nodes;
769 assert!(
770 members.windows(2).all(|pair| pair[0] < pair[1]),
771 "SCC members are ordered by node index, got {members:?}"
772 );
773 }
774 let _ = sym_idx;
775 }
776
777 #[test]
779 fn symbol_kind_and_visibility_use_the_documented_casing() {
780 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
781 let symbol = crate::model::Symbol {
782 id: crate::model::SymbolId::new(3).unwrap(),
783 name: "helper".to_string(),
784 kind: crate::model::SymbolKind::TypeAlias,
785 language: LangId::Python,
786 file_path: PathBuf::from("a.py"),
787 source_range: sample_source_range(),
788 name_range: None,
789 visibility: Some(crate::model::Visibility::Public),
790 signature: None,
791 docstring: None,
792 is_async: false,
793 };
794 builder.add_file(PathBuf::from("a.py"), LangId::Python);
795 builder.add_symbol(&symbol).unwrap();
796 let graph = builder.build();
797 let scc = sample_scc_analysis();
798
799 let json = serialize_graph(&graph, &scc, 1, &OutputFormat::Json).unwrap();
800 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
801 let node = parsed["nodes"]
802 .as_array()
803 .unwrap()
804 .iter()
805 .find(|n| n["kind"] == "symbol");
806 assert!(node.is_some(), "the symbol node is serialized");
807 let node = node.unwrap();
808
809 assert_eq!(node["symbol_kind"], "type_alias");
810 assert_eq!(node["visibility"], "public");
811 }
812
813 #[test]
815 fn edge_confidence_is_always_serialized() {
816 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
817 let file_a = builder.add_file(PathBuf::from("a.py"), LangId::Python);
818 builder.add_file(PathBuf::from("b.py"), LangId::Python);
819 builder.add_import(file_a, PathBuf::from("b.py"));
820 let graph = builder.build();
821 let scc = sample_scc_analysis();
822
823 let json = serialize_graph(&graph, &scc, 1, &OutputFormat::Json).unwrap();
824 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
825 let edges = parsed["edges"].as_array().unwrap();
826 assert!(!edges.is_empty(), "the graph has at least one edge");
827 for edge in edges {
828 assert!(
829 edge["confidence"].is_number(),
830 "every edge carries a confidence, got {edge}"
831 );
832 }
833 }
834
835 #[test]
838 fn non_finite_confidence_is_counted_and_normalized() {
839 let mut graph = crate::graph::CodeGraph::new(SnapshotId::new(1).unwrap());
840 let a_id = crate::model::FileId::new(1).unwrap();
841 let b_id = crate::model::FileId::new(2).unwrap();
842 let a_idx = graph.add_node(NodeData::File(FileNode::new(
843 a_id,
844 PathBuf::from("a.py"),
845 LangId::Python,
846 SnapshotId::new(1).unwrap(),
847 )));
848 let b_idx = graph.add_node(NodeData::File(FileNode::new(
849 b_id,
850 PathBuf::from("b.py"),
851 LangId::Python,
852 SnapshotId::new(1).unwrap(),
853 )));
854 graph.file_to_index.insert(a_id, a_idx);
855 graph.file_to_index.insert(b_id, b_idx);
856 graph.add_edge_normalized(a_idx, b_idx, EdgeKind::Import, f32::NAN);
857
858 let scc = SccAnalysis::analyze(graph.graph());
859 let output = GraphOutput::from_graph(&graph, Some(&scc), 1);
860 let json = serde_json::to_string(&output).unwrap();
861 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
862 assert_eq!(
863 parsed["metadata"]["invalid_confidence_edges"], 1,
864 "the graph records the rejected confidence"
865 );
866 assert_eq!(parsed["edges"][0]["confidence"], 0.0);
867 }
868}