Skip to main content

meta_ast/output/
graph.rs

1//! Graph output serialization for dependency analysis results.
2//!
3//! Provides JSON serialization of the dependency graph, SCC analysis,
4//! deployability hints, and the portable DataGraph contract for external
5//! consumers (sinks, dashboards, CLI output).
6
7use serde::Serialize;
8
9use crate::graph::CodeGraph;
10use crate::graph::edge::EdgeKind;
11use crate::graph::node::NodeData;
12use crate::graph::scc::{DeployabilityHint, SccAnalysis};
13
14/// Version of the graph export schema.
15/// Incremented on breaking changes to the serialized structure.
16pub const SCHEMA_VERSION: u32 = 2;
17
18/// Complete graph analysis output for serialization.
19///
20/// Serves both the CLI `graph` output (with SCC/deployability) and the
21/// `--datagraph` sink export (where SCC analysis is optional).
22#[derive(Debug, Clone, Serialize)]
23#[non_exhaustive]
24pub struct GraphOutput {
25    /// Schema version for forward compatibility
26    pub schema_version: u32,
27    /// Analysis metadata
28    pub metadata: GraphMetadata,
29    /// Graph nodes (files, symbols, externals, data)
30    pub nodes: Vec<SerializedNode>,
31    /// Graph edges with kinds
32    pub edges: Vec<SerializedEdge>,
33    /// Strongly connected components (empty when SCC analysis skipped)
34    pub sccs: Vec<SerializedScc>,
35    /// Deployability summary statistics (None when SCC analysis skipped)
36    pub deployability: Option<DeployabilityStats>,
37}
38
39/// Metadata about the graph analysis.
40#[derive(Debug, Clone, Serialize)]
41#[non_exhaustive]
42pub struct GraphMetadata {
43    /// Snapshot identifier for this analysis
44    pub snapshot_id: u64,
45    /// Total number of nodes
46    pub node_count: usize,
47    /// Total number of edges
48    pub edge_count: usize,
49    /// Number of SCCs computed (0 when SCC analysis skipped)
50    pub scc_count: usize,
51    /// Number of file nodes
52    pub file_count: usize,
53    /// Number of symbol nodes
54    pub symbol_count: usize,
55    /// Number of data-bearing nodes
56    pub data_node_count: usize,
57    /// Edges whose confidence was not finite and was normalized to `0.0`
58    pub invalid_confidence_edges: usize,
59}
60
61/// Serialized node representation.
62#[derive(Debug, Clone, Serialize)]
63#[non_exhaustive]
64pub struct SerializedNode {
65    /// Node index in the graph
66    pub id: usize,
67    /// Node kind: "file", "symbol", "external", or "data"
68    pub kind: String,
69    /// File or external path.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub path: Option<String>,
72    /// Defining file path for symbol nodes.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub file_path: Option<String>,
75    /// Source range for symbol nodes.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub source_range: Option<crate::model::SourceRange>,
78    /// Language identifier (for file/external nodes)
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub language: Option<String>,
81    /// Symbol or data node name
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub name: Option<String>,
84    /// Symbol kind (for symbol nodes)
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub symbol_kind: Option<String>,
87    /// Visibility (for symbol nodes)
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub visibility: Option<String>,
90    /// Data scope classification (local, parameter, member, etc.)
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub data_scope: Option<String>,
93    /// Type hint or annotation (for data nodes)
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub type_hint: Option<String>,
96}
97
98/// Serialized edge representation.
99#[derive(Debug, Clone, Serialize)]
100#[non_exhaustive]
101pub struct SerializedEdge {
102    /// Source node index
103    pub source: usize,
104    /// Target node index
105    pub target: usize,
106    /// Edge kind: "ownership", "import", "reference", or "flow"
107    pub kind: String,
108    /// Confidence score in the range 0.0-1.0.
109    ///
110    /// Always serialized: absence must not have to mean `1.0`. A non-finite
111    /// value is corruption and is written as `0.0` (see
112    /// `GraphMetadata::invalid_confidence_edges`).
113    pub confidence: f32,
114    /// Flow kind for dataflow edges (def_use, argument, return, field_access)
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub flow_kind: Option<String>,
117}
118
119/// Serialized SCC representation.
120#[derive(Debug, Clone, Serialize)]
121#[non_exhaustive]
122pub struct SerializedScc {
123    /// Component index
124    pub index: usize,
125    /// Node indices in this component
126    pub nodes: Vec<usize>,
127    /// Whether this component contains cycles
128    pub is_cyclic: bool,
129    /// Deployability hint
130    pub hint: String,
131    /// Component size
132    pub size: usize,
133}
134
135/// Deployability statistics summary.
136#[derive(Debug, Clone, Serialize)]
137#[non_exhaustive]
138pub struct DeployabilityStats {
139    /// Number of cyclic clusters (size > 1 or self-loop)
140    pub cyclic_clusters: usize,
141    /// Number of independent deployable units (size = 1, no self-loop)
142    pub independent_units: usize,
143    /// Number of self-loops (size = 1 with self-loop)
144    pub self_loops: usize,
145    /// Total number of components
146    pub total_components: usize,
147}
148
149impl GraphOutput {
150    /// Create a GraphOutput from a CodeGraph and optional SCC analysis.
151    ///
152    /// When `scc_analysis` is `None`, SCCs and deployability stats are
153    /// omitted from the output (empty/skipped in serialization).
154    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                // A non-finite confidence is corruption. It is reported in the
286                // metadata and written as 0.0, never omitted.
287                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
359/// Serialize graph output to the specified format.
360pub 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
370/// Order key for serialized nodes: kind group, then location, then node id.
371///
372/// Insertion order depends on how the graph was built; the output order must
373/// not. Files group first (paths), then symbols (defining file, name), then
374/// externals and data nodes (names).
375fn 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    // ── GraphOutput tests ───────────────────────────────────────────
423
424    #[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        // sccs is skip_serializing_if empty, so may be absent when empty
482        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        // Verify SCC fields exist (empty array is skipped in serialization,
512        // but the key is present when Some(SccAnalysis) is passed)
513        let _sccs = &parsed["sccs"];
514        // Verify deployability stats exist
515        let _deploy = &parsed["deployability"];
516    }
517
518    // ── Data node serialization tests ───────────────────────────────
519
520    #[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    // ── Light mode (no SCC) tests ───────────────────────────────────
610
611    #[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        // sccs is skipped when empty, deployability is null when None
623        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); // file + symbol
650        assert_eq!(output.edges.len(), 1); // ownership
651        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    // ── Edge kind validation ────────────────────────────────────────
672
673    #[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    /// Graph nodes are ordered by contract, not by insertion order.
717    #[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        // Insertion order is deliberately not the canonical order.
724        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    /// The casing contract is lower case for kinds and visibility.
778    #[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    /// Absence of the confidence field must not carry meaning.
814    #[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    /// A non-finite confidence is data corruption, so it is counted and
836    /// normalized instead of being written out as an absent field.
837    #[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}