Skip to main content

grapha_core/
semantic.rs

1use std::collections::{HashMap, HashSet};
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::extract::ExtractionResult;
7use crate::graph::{
8    Edge, EdgeKind, EdgeProvenance, FlowDirection, Node, NodeKind, NodeRole, Span, TerminalKind,
9    Visibility,
10};
11use crate::resolve::Import;
12
13const META_L10N_REF_KIND: &str = "l10n.ref_kind";
14const META_L10N_WRAPPER_NAME: &str = "l10n.wrapper_name";
15const META_L10N_WRAPPER_BASE: &str = "l10n.wrapper_base";
16const META_L10N_WRAPPER_SYMBOL: &str = "l10n.wrapper_symbol";
17const META_L10N_TABLE: &str = "l10n.table";
18const META_L10N_KEY: &str = "l10n.key";
19const META_L10N_FALLBACK: &str = "l10n.fallback";
20const META_L10N_ARG_COUNT: &str = "l10n.arg_count";
21const META_L10N_LITERAL: &str = "l10n.literal";
22const META_L10N_ARGUMENT_LABEL: &str = "l10n.argument_label";
23const META_L10N_WRAPPER_TABLE: &str = "l10n.wrapper.table";
24const META_L10N_WRAPPER_KEY: &str = "l10n.wrapper.key";
25const META_L10N_WRAPPER_FALLBACK: &str = "l10n.wrapper.fallback";
26const META_L10N_WRAPPER_ARG_COUNT: &str = "l10n.wrapper.arg_count";
27const META_ASSET_REF_KIND: &str = "asset.ref_kind";
28const META_ASSET_NAME: &str = "asset.name";
29const META_SWIFTUI_INVALIDATION_SOURCE: &str = "swiftui.invalidation_source";
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct SemanticDocument {
33    pub symbols: Vec<SemanticSymbol>,
34    pub relations: Vec<SemanticRelation>,
35    pub artifacts: Vec<SemanticArtifact>,
36    pub imports: Vec<Import>,
37}
38
39impl Default for SemanticDocument {
40    fn default() -> Self {
41        Self::new()
42    }
43}
44
45impl SemanticDocument {
46    pub fn new() -> Self {
47        Self {
48            symbols: Vec::new(),
49            relations: Vec::new(),
50            artifacts: Vec::new(),
51            imports: Vec::new(),
52        }
53    }
54
55    pub fn from_extraction_result(result: ExtractionResult) -> Self {
56        let mut document = Self::new();
57        document.imports = result.imports;
58        let symbol_ids: HashSet<String> = result.nodes.iter().map(|node| node.id.clone()).collect();
59
60        for node in result.nodes {
61            let (symbol, mut artifacts) = SemanticSymbol::from_node(node);
62            document.symbols.push(symbol);
63            document.artifacts.append(&mut artifacts);
64        }
65
66        for edge in result.edges {
67            document
68                .relations
69                .push(SemanticRelation::from_edge(edge, &symbol_ids));
70        }
71
72        document
73    }
74
75    pub fn into_extraction_result(self) -> ExtractionResult {
76        let mut relation_terminal_roles: HashMap<String, TerminalKind> = HashMap::new();
77        let symbol_ids: HashSet<&str> = self
78            .symbols
79            .iter()
80            .map(|symbol| symbol.id.as_str())
81            .collect();
82
83        for relation in &self.relations {
84            let Some(kind) = relation.terminal_kind else {
85                continue;
86            };
87
88            let terminal_symbol_id = match &relation.target {
89                SemanticTarget::Symbol(symbol_id) if symbol_ids.contains(symbol_id.as_str()) => {
90                    symbol_id.clone()
91                }
92                _ => relation.source.clone(),
93            };
94            relation_terminal_roles
95                .entry(terminal_symbol_id)
96                .or_insert(kind);
97        }
98
99        let mut artifact_metadata: HashMap<&str, HashMap<String, String>> = HashMap::new();
100        for artifact in &self.artifacts {
101            let metadata = artifact_metadata.entry(artifact.symbol_id()).or_default();
102            artifact.write_metadata(metadata);
103        }
104
105        let nodes = self
106            .symbols
107            .into_iter()
108            .map(|symbol| {
109                let mut node = symbol.into_node();
110
111                if let Some(metadata) = artifact_metadata.remove(node.id.as_str()) {
112                    node.metadata.extend(metadata);
113                }
114
115                if node.role.is_none()
116                    && let Some(kind) = relation_terminal_roles.get(node.id.as_str())
117                {
118                    node.role = Some(NodeRole::Terminal { kind: *kind });
119                }
120
121                node
122            })
123            .collect();
124
125        let edges = self
126            .relations
127            .into_iter()
128            .map(SemanticRelation::into_edge)
129            .collect();
130
131        ExtractionResult {
132            nodes,
133            edges,
134            imports: self.imports,
135        }
136    }
137
138    pub fn annotate_call_relations<F>(&mut self, mut classify: F)
139    where
140        F: FnMut(&SemanticRelation, Option<&SemanticSymbol>) -> Option<TerminalEffect>,
141    {
142        self.apply_call_relation_effects(&mut classify, false);
143    }
144
145    pub fn override_call_relations<F>(&mut self, mut classify: F)
146    where
147        F: FnMut(&SemanticRelation, Option<&SemanticSymbol>) -> Option<TerminalEffect>,
148    {
149        self.apply_call_relation_effects(&mut classify, true);
150    }
151
152    fn apply_call_relation_effects<F>(&mut self, classify: &mut F, overwrite_existing: bool)
153    where
154        F: FnMut(&SemanticRelation, Option<&SemanticSymbol>) -> Option<TerminalEffect>,
155    {
156        let symbols_by_id: HashMap<&str, &SemanticSymbol> = self
157            .symbols
158            .iter()
159            .map(|symbol| (symbol.id.as_str(), symbol))
160            .collect();
161
162        for relation in &mut self.relations {
163            if relation.kind != EdgeKind::Calls {
164                continue;
165            }
166
167            if !overwrite_existing
168                && (relation.direction.is_some()
169                    || relation.operation.is_some()
170                    || relation.terminal_kind.is_some())
171            {
172                continue;
173            }
174
175            let Some(effect) = classify(
176                relation,
177                symbols_by_id.get(relation.source.as_str()).copied(),
178            ) else {
179                continue;
180            };
181
182            relation.terminal_kind = Some(effect.terminal_kind);
183            relation.direction = Some(effect.direction);
184            relation.operation = Some(effect.operation);
185        }
186    }
187
188    pub fn stamp_module(mut self, module_name: Option<&str>) -> Self {
189        let Some(module_name) = module_name else {
190            return self;
191        };
192
193        for symbol in &mut self.symbols {
194            symbol.module.get_or_insert_with(|| module_name.to_string());
195        }
196
197        self
198    }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct SemanticSymbol {
203    pub id: String,
204    pub kind: NodeKind,
205    pub name: String,
206    pub file: PathBuf,
207    pub span: Span,
208    pub visibility: Visibility,
209    pub properties: HashMap<String, String>,
210    pub annotations: Vec<SemanticAnnotation>,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub signature: Option<String>,
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub doc_comment: Option<String>,
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub module: Option<String>,
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub snippet: Option<String>,
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub synthetic_kind: Option<String>,
221}
222
223impl SemanticSymbol {
224    fn from_node(mut node: Node) -> (Self, Vec<SemanticArtifact>) {
225        let mut annotations = Vec::new();
226
227        if let Some(role) = node.role.take() {
228            match role {
229                NodeRole::EntryPoint => annotations.push(SemanticAnnotation::EntryPoint),
230                NodeRole::Terminal { kind } => {
231                    annotations.push(SemanticAnnotation::Terminal { kind });
232                }
233                NodeRole::Internal => annotations.push(SemanticAnnotation::Internal),
234            }
235        }
236
237        if let Some(value) = node.metadata.remove(META_SWIFTUI_INVALIDATION_SOURCE) {
238            annotations.push(SemanticAnnotation::Flag {
239                key: META_SWIFTUI_INVALIDATION_SOURCE.to_string(),
240                value,
241            });
242        }
243
244        let artifacts = extract_symbol_artifacts(&mut node.metadata, node.id.clone());
245        let synthetic_kind = match node.kind {
246            NodeKind::View => Some("swiftui_view".to_string()),
247            NodeKind::Branch => Some("swiftui_branch".to_string()),
248            _ => None,
249        };
250
251        let symbol = Self {
252            id: node.id,
253            kind: node.kind,
254            name: node.name,
255            file: node.file,
256            span: node.span,
257            visibility: node.visibility,
258            properties: node.metadata,
259            annotations,
260            signature: node.signature,
261            doc_comment: node.doc_comment,
262            module: node.module,
263            snippet: node.snippet,
264            synthetic_kind,
265        };
266
267        (symbol, artifacts)
268    }
269
270    fn into_node(self) -> Node {
271        let mut role = None;
272        let mut metadata = self.properties;
273
274        for annotation in &self.annotations {
275            match annotation {
276                SemanticAnnotation::EntryPoint if role.is_none() => {
277                    role = Some(NodeRole::EntryPoint);
278                }
279                SemanticAnnotation::Terminal { kind } if role.is_none() => {
280                    role = Some(NodeRole::Terminal { kind: *kind });
281                }
282                SemanticAnnotation::Internal if role.is_none() => {
283                    role = Some(NodeRole::Internal);
284                }
285                SemanticAnnotation::Flag { key, value } => {
286                    metadata.insert(key.clone(), value.clone());
287                }
288                _ => {}
289            }
290        }
291
292        Node {
293            id: self.id,
294            kind: self.kind,
295            name: self.name,
296            file: self.file,
297            span: self.span,
298            visibility: self.visibility,
299            metadata,
300            role,
301            signature: self.signature,
302            doc_comment: self.doc_comment,
303            module: self.module,
304            snippet: self.snippet,
305        }
306    }
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(rename_all = "snake_case")]
311pub enum ArtifactKind {
312    LocalizationRef,
313    LocalizationWrapperBinding,
314    AssetRef,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(rename_all = "snake_case", tag = "kind")]
319pub enum SemanticArtifact {
320    LocalizationRef {
321        symbol_id: String,
322        ref_kind: String,
323        #[serde(skip_serializing_if = "Option::is_none")]
324        wrapper_name: Option<String>,
325        #[serde(skip_serializing_if = "Option::is_none")]
326        wrapper_base: Option<String>,
327        #[serde(skip_serializing_if = "Option::is_none")]
328        wrapper_symbol: Option<String>,
329        #[serde(skip_serializing_if = "Option::is_none")]
330        table: Option<String>,
331        #[serde(skip_serializing_if = "Option::is_none")]
332        key: Option<String>,
333        #[serde(skip_serializing_if = "Option::is_none")]
334        fallback: Option<String>,
335        #[serde(skip_serializing_if = "Option::is_none")]
336        arg_count: Option<usize>,
337        #[serde(skip_serializing_if = "Option::is_none")]
338        literal: Option<String>,
339        #[serde(skip_serializing_if = "Option::is_none")]
340        argument_label: Option<String>,
341    },
342    LocalizationWrapperBinding {
343        symbol_id: String,
344        table: String,
345        key: String,
346        #[serde(skip_serializing_if = "Option::is_none")]
347        fallback: Option<String>,
348        #[serde(skip_serializing_if = "Option::is_none")]
349        arg_count: Option<usize>,
350    },
351    AssetRef {
352        symbol_id: String,
353        ref_kind: String,
354        name: String,
355    },
356}
357
358impl SemanticArtifact {
359    pub fn symbol_id(&self) -> &str {
360        match self {
361            SemanticArtifact::LocalizationRef { symbol_id, .. }
362            | SemanticArtifact::LocalizationWrapperBinding { symbol_id, .. }
363            | SemanticArtifact::AssetRef { symbol_id, .. } => symbol_id,
364        }
365    }
366
367    pub fn kind(&self) -> ArtifactKind {
368        match self {
369            SemanticArtifact::LocalizationRef { .. } => ArtifactKind::LocalizationRef,
370            SemanticArtifact::LocalizationWrapperBinding { .. } => {
371                ArtifactKind::LocalizationWrapperBinding
372            }
373            SemanticArtifact::AssetRef { .. } => ArtifactKind::AssetRef,
374        }
375    }
376
377    fn write_metadata(&self, metadata: &mut HashMap<String, String>) {
378        match self {
379            SemanticArtifact::LocalizationRef {
380                ref_kind,
381                wrapper_name,
382                wrapper_base,
383                wrapper_symbol,
384                table,
385                key,
386                fallback,
387                arg_count,
388                literal,
389                argument_label,
390                ..
391            } => {
392                metadata.insert(META_L10N_REF_KIND.to_string(), ref_kind.clone());
393                if let Some(value) = wrapper_name {
394                    metadata.insert(META_L10N_WRAPPER_NAME.to_string(), value.clone());
395                }
396                if let Some(value) = wrapper_base {
397                    metadata.insert(META_L10N_WRAPPER_BASE.to_string(), value.clone());
398                }
399                if let Some(value) = wrapper_symbol {
400                    metadata.insert(META_L10N_WRAPPER_SYMBOL.to_string(), value.clone());
401                }
402                if let Some(value) = table {
403                    metadata.insert(META_L10N_TABLE.to_string(), value.clone());
404                }
405                if let Some(value) = key {
406                    metadata.insert(META_L10N_KEY.to_string(), value.clone());
407                }
408                if let Some(value) = fallback {
409                    metadata.insert(META_L10N_FALLBACK.to_string(), value.clone());
410                }
411                if let Some(value) = arg_count {
412                    metadata.insert(META_L10N_ARG_COUNT.to_string(), value.to_string());
413                }
414                if let Some(value) = literal {
415                    metadata.insert(META_L10N_LITERAL.to_string(), value.clone());
416                }
417                if let Some(value) = argument_label {
418                    metadata.insert(META_L10N_ARGUMENT_LABEL.to_string(), value.clone());
419                }
420            }
421            SemanticArtifact::LocalizationWrapperBinding {
422                table,
423                key,
424                fallback,
425                arg_count,
426                ..
427            } => {
428                metadata.insert(META_L10N_WRAPPER_TABLE.to_string(), table.clone());
429                metadata.insert(META_L10N_WRAPPER_KEY.to_string(), key.clone());
430                if let Some(value) = fallback {
431                    metadata.insert(META_L10N_WRAPPER_FALLBACK.to_string(), value.clone());
432                }
433                if let Some(value) = arg_count {
434                    metadata.insert(META_L10N_WRAPPER_ARG_COUNT.to_string(), value.to_string());
435                }
436            }
437            SemanticArtifact::AssetRef { ref_kind, name, .. } => {
438                metadata.insert(META_ASSET_REF_KIND.to_string(), ref_kind.clone());
439                metadata.insert(META_ASSET_NAME.to_string(), name.clone());
440            }
441        }
442    }
443}
444
445#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
446#[serde(rename_all = "snake_case", tag = "type")]
447pub enum SemanticAnnotation {
448    EntryPoint,
449    Terminal { kind: TerminalKind },
450    Internal,
451    Flag { key: String, value: String },
452}
453
454#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
455pub struct SemanticRelation {
456    pub source: String,
457    pub target: SemanticTarget,
458    pub kind: EdgeKind,
459    pub confidence: f64,
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub direction: Option<FlowDirection>,
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub operation: Option<String>,
464    #[serde(skip_serializing_if = "Option::is_none")]
465    pub condition: Option<String>,
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub async_boundary: Option<bool>,
468    #[serde(default, skip_serializing_if = "Vec::is_empty")]
469    pub provenance: Vec<EdgeProvenance>,
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub terminal_kind: Option<TerminalKind>,
472}
473
474impl SemanticRelation {
475    fn from_edge(edge: Edge, symbol_ids: &HashSet<String>) -> Self {
476        let target = if symbol_ids.contains(&edge.target) {
477            SemanticTarget::Symbol(edge.target)
478        } else {
479            SemanticTarget::ExternalRef(edge.target)
480        };
481        Self {
482            source: edge.source,
483            target,
484            kind: edge.kind,
485            confidence: edge.confidence,
486            direction: edge.direction,
487            operation: edge.operation,
488            condition: edge.condition,
489            async_boundary: edge.async_boundary,
490            provenance: edge.provenance,
491            terminal_kind: None,
492        }
493    }
494
495    fn into_edge(self) -> Edge {
496        Edge {
497            source: self.source,
498            target: self.target.into_raw(),
499            kind: self.kind,
500            confidence: self.confidence,
501            direction: self.direction,
502            operation: self.operation,
503            condition: self.condition,
504            async_boundary: self.async_boundary,
505            provenance: self.provenance,
506        }
507    }
508}
509
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
511#[serde(rename_all = "snake_case", tag = "type", content = "value")]
512pub enum SemanticTarget {
513    Symbol(String),
514    ExternalRef(String),
515}
516
517impl SemanticTarget {
518    pub fn as_raw(&self) -> &str {
519        match self {
520            SemanticTarget::Symbol(value) | SemanticTarget::ExternalRef(value) => value,
521        }
522    }
523
524    fn into_raw(self) -> String {
525        match self {
526            SemanticTarget::Symbol(value) | SemanticTarget::ExternalRef(value) => value,
527        }
528    }
529}
530
531#[derive(Debug, Clone, PartialEq, Eq)]
532pub struct TerminalEffect {
533    pub terminal_kind: TerminalKind,
534    pub direction: FlowDirection,
535    pub operation: String,
536}
537
538fn extract_symbol_artifacts(
539    metadata: &mut HashMap<String, String>,
540    symbol_id: String,
541) -> Vec<SemanticArtifact> {
542    let mut artifacts = Vec::new();
543
544    let wrapper_binding = (
545        metadata.remove(META_L10N_WRAPPER_TABLE),
546        metadata.remove(META_L10N_WRAPPER_KEY),
547    );
548    if let (Some(table), Some(key)) = wrapper_binding {
549        let fallback = metadata.remove(META_L10N_WRAPPER_FALLBACK);
550        let arg_count = metadata
551            .remove(META_L10N_WRAPPER_ARG_COUNT)
552            .and_then(|value| value.parse::<usize>().ok());
553        artifacts.push(SemanticArtifact::LocalizationWrapperBinding {
554            symbol_id: symbol_id.clone(),
555            table,
556            key,
557            fallback,
558            arg_count,
559        });
560    }
561
562    let localization_ref = metadata.remove(META_L10N_REF_KIND);
563    if let Some(ref_kind) = localization_ref {
564        let table = metadata.remove(META_L10N_TABLE);
565        let key = metadata.remove(META_L10N_KEY);
566        let fallback = metadata.remove(META_L10N_FALLBACK);
567        let arg_count = metadata
568            .remove(META_L10N_ARG_COUNT)
569            .and_then(|value| value.parse::<usize>().ok());
570        let literal = metadata.remove(META_L10N_LITERAL);
571        let wrapper_name = metadata.remove(META_L10N_WRAPPER_NAME);
572        let wrapper_base = metadata.remove(META_L10N_WRAPPER_BASE);
573        let wrapper_symbol = metadata.remove(META_L10N_WRAPPER_SYMBOL);
574        let argument_label = metadata.remove(META_L10N_ARGUMENT_LABEL);
575
576        artifacts.push(SemanticArtifact::LocalizationRef {
577            symbol_id: symbol_id.clone(),
578            ref_kind,
579            wrapper_name,
580            wrapper_base,
581            wrapper_symbol,
582            table,
583            key,
584            fallback,
585            arg_count,
586            literal,
587            argument_label,
588        });
589    }
590
591    let asset_ref = (
592        metadata.remove(META_ASSET_REF_KIND),
593        metadata.remove(META_ASSET_NAME),
594    );
595    if let (Some(ref_kind), Some(name)) = asset_ref {
596        artifacts.push(SemanticArtifact::AssetRef {
597            symbol_id,
598            ref_kind,
599            name,
600        });
601    }
602
603    artifacts
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    fn test_span() -> Span {
611        Span {
612            start: [1, 0],
613            end: [2, 0],
614        }
615    }
616
617    #[test]
618    fn round_trips_known_metadata_into_typed_artifacts() {
619        let mut metadata = HashMap::new();
620        metadata.insert(META_L10N_REF_KIND.to_string(), "literal".to_string());
621        metadata.insert(META_L10N_LITERAL.to_string(), "Hello".to_string());
622        metadata.insert(META_ASSET_REF_KIND.to_string(), "image".to_string());
623        metadata.insert(META_ASSET_NAME.to_string(), "hero".to_string());
624        metadata.insert(
625            META_SWIFTUI_INVALIDATION_SOURCE.to_string(),
626            "true".to_string(),
627        );
628        metadata.insert("async".to_string(), "true".to_string());
629
630        let result = ExtractionResult {
631            nodes: vec![Node {
632                id: "body".to_string(),
633                kind: NodeKind::View,
634                name: "Text".to_string(),
635                file: PathBuf::from("ContentView.swift"),
636                span: test_span(),
637                visibility: Visibility::Public,
638                metadata,
639                role: Some(NodeRole::EntryPoint),
640                signature: Some("var body: some View".to_string()),
641                doc_comment: None,
642                module: Some("Demo".to_string()),
643                snippet: None,
644            }],
645            edges: Vec::new(),
646            imports: Vec::new(),
647        };
648
649        let document = SemanticDocument::from_extraction_result(result);
650        assert_eq!(document.symbols.len(), 1);
651        assert_eq!(document.artifacts.len(), 2);
652        assert!(
653            document.symbols[0]
654                .annotations
655                .contains(&SemanticAnnotation::EntryPoint)
656        );
657        assert!(
658            document.symbols[0]
659                .annotations
660                .contains(&SemanticAnnotation::Flag {
661                    key: META_SWIFTUI_INVALIDATION_SOURCE.to_string(),
662                    value: "true".to_string(),
663                })
664        );
665        assert_eq!(
666            document.symbols[0]
667                .properties
668                .get("async")
669                .map(String::as_str),
670            Some("true")
671        );
672
673        let lowered = document.into_extraction_result();
674        let node = &lowered.nodes[0];
675        assert_eq!(node.role, Some(NodeRole::EntryPoint));
676        assert_eq!(
677            node.metadata.get(META_L10N_REF_KIND).map(String::as_str),
678            Some("literal")
679        );
680        assert_eq!(
681            node.metadata.get(META_ASSET_NAME).map(String::as_str),
682            Some("hero")
683        );
684        assert_eq!(
685            node.metadata
686                .get(META_SWIFTUI_INVALIDATION_SOURCE)
687                .map(String::as_str),
688            Some("true")
689        );
690        assert_eq!(node.metadata.get("async").map(String::as_str), Some("true"));
691    }
692
693    #[test]
694    fn relation_terminal_effect_marks_source_node_when_target_is_external() {
695        let mut document = SemanticDocument::new();
696        document.symbols.push(SemanticSymbol {
697            id: "caller".to_string(),
698            kind: NodeKind::Function,
699            name: "load".to_string(),
700            file: PathBuf::from("main.rs"),
701            span: test_span(),
702            visibility: Visibility::Public,
703            properties: HashMap::new(),
704            annotations: Vec::new(),
705            signature: None,
706            doc_comment: None,
707            module: None,
708            snippet: None,
709            synthetic_kind: None,
710        });
711        document.relations.push(SemanticRelation {
712            source: "caller".to_string(),
713            target: SemanticTarget::ExternalRef("reqwest::get".to_string()),
714            kind: EdgeKind::Calls,
715            confidence: 1.0,
716            direction: Some(FlowDirection::Read),
717            operation: Some("HTTP".to_string()),
718            condition: None,
719            async_boundary: None,
720            provenance: Vec::new(),
721            terminal_kind: Some(TerminalKind::Network),
722        });
723
724        let lowered = document.into_extraction_result();
725        assert_eq!(
726            lowered.nodes[0].role,
727            Some(NodeRole::Terminal {
728                kind: TerminalKind::Network,
729            })
730        );
731        assert_eq!(lowered.edges[0].direction, Some(FlowDirection::Read));
732    }
733
734    #[test]
735    fn annotate_call_relations_uses_source_symbol_context() {
736        let mut document = SemanticDocument::new();
737        document.symbols.push(SemanticSymbol {
738            id: "caller".to_string(),
739            kind: NodeKind::Function,
740            name: "load".to_string(),
741            file: PathBuf::from("main.rs"),
742            span: test_span(),
743            visibility: Visibility::Public,
744            properties: HashMap::new(),
745            annotations: Vec::new(),
746            signature: None,
747            doc_comment: None,
748            module: None,
749            snippet: None,
750            synthetic_kind: None,
751        });
752        document.relations.push(SemanticRelation {
753            source: "caller".to_string(),
754            target: SemanticTarget::ExternalRef("reqwest::get".to_string()),
755            kind: EdgeKind::Calls,
756            confidence: 1.0,
757            direction: None,
758            operation: None,
759            condition: None,
760            async_boundary: None,
761            provenance: Vec::new(),
762            terminal_kind: None,
763        });
764
765        document.annotate_call_relations(|relation, source| {
766            assert_eq!(relation.target.as_raw(), "reqwest::get");
767            assert_eq!(source.map(|symbol| symbol.name.as_str()), Some("load"));
768            Some(TerminalEffect {
769                terminal_kind: TerminalKind::Network,
770                direction: FlowDirection::Read,
771                operation: "HTTP".to_string(),
772            })
773        });
774
775        assert_eq!(
776            document.relations[0].terminal_kind,
777            Some(TerminalKind::Network)
778        );
779        assert_eq!(document.relations[0].operation.as_deref(), Some("HTTP"));
780    }
781
782    #[test]
783    fn override_call_relations_replaces_existing_effect() {
784        let mut document = SemanticDocument::new();
785        document.symbols.push(SemanticSymbol {
786            id: "caller".to_string(),
787            kind: NodeKind::Function,
788            name: "load".to_string(),
789            file: PathBuf::from("main.rs"),
790            span: test_span(),
791            visibility: Visibility::Public,
792            properties: HashMap::new(),
793            annotations: Vec::new(),
794            signature: None,
795            doc_comment: None,
796            module: None,
797            snippet: None,
798            synthetic_kind: None,
799        });
800        document.relations.push(SemanticRelation {
801            source: "caller".to_string(),
802            target: SemanticTarget::ExternalRef("reqwest::get".to_string()),
803            kind: EdgeKind::Calls,
804            confidence: 1.0,
805            direction: Some(FlowDirection::Read),
806            operation: Some("HTTP".to_string()),
807            condition: None,
808            async_boundary: None,
809            provenance: Vec::new(),
810            terminal_kind: Some(TerminalKind::Network),
811        });
812
813        document.override_call_relations(|_, _| {
814            Some(TerminalEffect {
815                terminal_kind: TerminalKind::Event,
816                direction: FlowDirection::Write,
817                operation: "CUSTOM".to_string(),
818            })
819        });
820
821        assert_eq!(
822            document.relations[0].terminal_kind,
823            Some(TerminalKind::Event)
824        );
825        assert_eq!(document.relations[0].direction, Some(FlowDirection::Write));
826        assert_eq!(document.relations[0].operation.as_deref(), Some("CUSTOM"));
827    }
828}