Skip to main content

meta_ast/graph/
builder.rs

1//! Graph builder for incremental construction from extraction results.
2//!
3//! Construction proceeds in stages:
4//! 1. Ownership graph: files and their symbols
5//! 2. Dataflow nodes and edges (requires `--features dataflow`)
6//! 3. Dependency graph: imports and cross-file references
7//!
8//! The builder maintains index mappings from domain IDs (FileId, SymbolId)
9//! to petgraph `NodeIndex` for efficient lookups.
10
11use std::collections::HashMap;
12use std::path::PathBuf;
13
14use petgraph::graph::{DiGraph, EdgeIndex, NodeIndex};
15use petgraph::visit::EdgeRef;
16
17use crate::graph::CodeGraph;
18use crate::graph::edge::{CONFIDENCE_CROSS_LANGUAGE, CONFIDENCE_OWN_OR_DIRECT, EdgeData, EdgeKind};
19#[cfg(feature = "dataflow")]
20use crate::graph::node::DataGraphNode;
21use crate::graph::node::{ExternalNode, FileNode, NodeData, SymbolNode};
22use crate::language::LangId;
23#[cfg(feature = "dataflow")]
24use crate::model::DataNodeId;
25use crate::model::{FileId, IdGenerator, SnapshotId, Symbol, SymbolId};
26
27/// Builder for incremental graph construction from extraction results.
28#[derive(Debug)]
29pub struct GraphBuilder {
30    /// Underlying graph being constructed
31    graph: DiGraph<NodeData, EdgeData>,
32
33    /// Map from FileId to graph node index
34    file_to_index: HashMap<FileId, NodeIndex>,
35
36    /// Map from SymbolId to graph node index
37    symbol_to_index: HashMap<SymbolId, NodeIndex>,
38
39    /// Map from file path to FileId (for resolving import targets)
40    path_to_file: HashMap<PathBuf, FileId>,
41
42    /// ID generator for FileIds
43    file_id_gen: IdGenerator<FileId>,
44
45    /// Snapshot ID for this analysis
46    snapshot_id: SnapshotId,
47
48    /// O(1) dedup index mirroring `CodeGraph::edge_index`.
49    edge_index: HashMap<(NodeIndex, NodeIndex, EdgeKind), EdgeIndex>,
50
51    /// Map from external raw path to graph node index
52    external_index: HashMap<String, NodeIndex>,
53
54    /// Map from DataNodeId to graph node index
55    #[cfg(feature = "dataflow")]
56    data_to_index: HashMap<DataNodeId, NodeIndex>,
57}
58
59impl GraphBuilder {
60    /// Creates a new GraphBuilder for the given snapshot.
61    pub fn new(snapshot_id: SnapshotId) -> Self {
62        Self {
63            graph: DiGraph::new(),
64            file_to_index: HashMap::new(),
65            symbol_to_index: HashMap::new(),
66            path_to_file: HashMap::new(),
67            file_id_gen: IdGenerator::new(),
68            snapshot_id,
69            edge_index: HashMap::new(),
70            external_index: HashMap::new(),
71            #[cfg(feature = "dataflow")]
72            data_to_index: HashMap::new(),
73        }
74    }
75
76    /// Adds a file node to the graph.
77    ///
78    /// If the file already exists (determined by path), returns the existing FileId.
79    pub fn add_file(&mut self, path: PathBuf, language: LangId) -> FileId {
80        // Check if file already exists
81        if let Some(&existing_id) = self.path_to_file.get(&path) {
82            return existing_id;
83        }
84
85        let id = self.file_id_gen.next();
86
87        let node = FileNode {
88            id,
89            path: path.clone(),
90            language,
91            snapshot_id: self.snapshot_id,
92        };
93
94        let idx = self.graph.add_node(NodeData::File(node));
95        self.file_to_index.insert(id, idx);
96        self.path_to_file.insert(path, id);
97
98        id
99    }
100
101    /// Adds a symbol node and its ownership edge to the containing file.
102    ///
103    /// The containing file must already exist in the builder. The symbol's
104    /// file path is resolved against registered files.
105    ///
106    /// Returns the NodeIndex for the symbol node.
107    pub fn add_symbol(&mut self, symbol: &Symbol) -> Result<NodeIndex, crate::Error> {
108        // Check if symbol already exists
109        if let Some(&existing) = self.symbol_to_index.get(&symbol.id) {
110            return Ok(existing);
111        }
112
113        // Look up the file by path
114        let file_id = *self
115            .path_to_file
116            .get(&symbol.file_path)
117            .ok_or_else(|| crate::Error::Graph("file must be added before its symbols".into()))?;
118
119        let file_idx = *self
120            .file_to_index
121            .get(&file_id)
122            .ok_or_else(|| crate::Error::Graph("file index must exist".into()))?;
123
124        let node = SymbolNode {
125            id: symbol.id,
126            name: symbol.name.clone(),
127            kind: symbol.kind,
128            file_id,
129            visibility: symbol.visibility,
130            source_range: symbol.source_range.clone(),
131        };
132
133        let sym_idx = self.graph.add_node(NodeData::Symbol(node));
134        self.symbol_to_index.insert(symbol.id, sym_idx);
135
136        // Add ownership edge: file -> symbol (always full confidence)
137        self.add_edge_internal(file_idx, sym_idx, EdgeKind::Ownership, 1.0);
138
139        Ok(sym_idx)
140    }
141
142    /// Adds a data-bearing node to the graph.
143    ///
144    /// Returns the NodeIndex for the data node. Idempotent: returns
145    /// the existing index if a data node with the same DataNodeId exists.
146    ///
147    /// When the data node has a `symbol_id` that resolves to an existing
148    /// SymbolNode in the graph, an Ownership edge is created from the
149    /// symbol to the data node.
150    #[cfg(feature = "dataflow")]
151    pub fn add_data_node(&mut self, data_node: &crate::model::DataNode) -> NodeIndex {
152        if let Some(&existing) = self.data_to_index.get(&data_node.id) {
153            return existing;
154        }
155        let node = DataGraphNode {
156            id: data_node.id,
157            symbol_id: data_node.symbol_id,
158            name: data_node.name.clone(),
159            scope: data_node.scope,
160            type_hint: data_node.type_hint.clone(),
161            source_range: data_node.source_range.clone(),
162        };
163        let idx = self.graph.add_node(NodeData::Data(node));
164        self.data_to_index.insert(data_node.id, idx);
165
166        if let Some(symbol_id) = data_node.symbol_id
167            && let Some(&sym_idx) = self.symbol_to_index.get(&symbol_id)
168        {
169            self.add_edge_internal(sym_idx, idx, EdgeKind::Ownership, 1.0);
170        }
171
172        idx
173    }
174
175    /// Adds a flow edge between two data nodes.
176    #[cfg(feature = "dataflow")]
177    pub fn add_flow_edge(
178        &mut self,
179        source: crate::model::DataNodeId,
180        target: crate::model::DataNodeId,
181        kind: crate::model::FlowKind,
182        confidence: f32,
183    ) {
184        let Some(&src_idx) = self.data_to_index.get(&source) else {
185            return;
186        };
187        let Some(&tgt_idx) = self.data_to_index.get(&target) else {
188            return;
189        };
190        self.add_edge_internal_with_flow(src_idx, tgt_idx, EdgeKind::Flow, confidence, Some(kind));
191    }
192
193    /// Adds an import edge from one file to another.
194    ///
195    /// If the target file exists in the project, creates an import edge.
196    /// If the target is external (not in the project), creates an ExternalNode
197    /// placeholder and an import edge to it.
198    pub fn add_import(&mut self, from: FileId, to: PathBuf) {
199        let Some(&from_idx) = self.file_to_index.get(&from) else {
200            return; // Source not in graph
201        };
202        let Some(source_language) = self.graph[from_idx].as_file().map(|file| file.language) else {
203            return; // Index without a file node: the graph is inconsistent
204        };
205
206        // Resolve target path to file ID if it exists in our graph
207        if let Some(&to_id) = self.path_to_file.get(&to) {
208            if let Some(&to_idx) = self.file_to_index.get(&to_id) {
209                let target_language = self.graph[to_idx].as_file().map(|file| file.language);
210                let confidence = if target_language == Some(source_language) {
211                    CONFIDENCE_OWN_OR_DIRECT
212                } else {
213                    CONFIDENCE_CROSS_LANGUAGE
214                };
215                self.add_edge_internal(from_idx, to_idx, EdgeKind::Import, confidence);
216            }
217            return;
218        }
219
220        // External dependency: create or reuse external node
221        let raw_path = to.to_string_lossy().to_string();
222        let to_idx = if let Some(&idx) = self.external_index.get(&raw_path) {
223            idx
224        } else {
225            let node = ExternalNode {
226                raw_path: raw_path.clone(),
227                language: source_language,
228                classification: None,
229            };
230            let idx = self.graph.add_node(NodeData::External(node));
231            self.external_index.insert(raw_path, idx);
232            idx
233        };
234
235        self.add_edge_internal(from_idx, to_idx, EdgeKind::Import, CONFIDENCE_OWN_OR_DIRECT);
236    }
237
238    /// Internal edge addition with flow kind, respecting normalization.
239    #[cfg(feature = "dataflow")]
240    fn add_edge_internal_with_flow(
241        &mut self,
242        source: NodeIndex,
243        target: NodeIndex,
244        kind: EdgeKind,
245        confidence: f32,
246        flow_kind: Option<crate::model::FlowKind>,
247    ) {
248        self.insert_edge(source, target, kind, confidence, flow_kind);
249    }
250
251    /// Adds an edge through the one normalization rule: a repeated triple
252    /// merges into the existing edge.
253    fn insert_edge(
254        &mut self,
255        source: NodeIndex,
256        target: NodeIndex,
257        kind: EdgeKind,
258        confidence: f32,
259        flow_kind: Option<crate::model::FlowKind>,
260    ) {
261        let confidence = confidence.clamp(0.0, 1.0);
262        let key = (source, target, kind);
263        if let Some(&edge_idx) = self.edge_index.get(&key) {
264            self.graph[edge_idx].merge_repeated(confidence, flow_kind);
265            return;
266        }
267        let mut edge_data = EdgeData::with_confidence(kind, confidence);
268        edge_data.flow_kind = flow_kind;
269        let edge_idx = self.graph.add_edge(source, target, edge_data);
270        self.edge_index.insert(key, edge_idx);
271    }
272
273    /// Adds a reference edge between two symbols with a confidence score.
274    ///
275    /// Both symbols must exist in the builder. If a duplicate edge
276    /// already exists, the confidence is merged via max (the stronger
277    /// signal is preserved).
278    pub fn add_reference(&mut self, from: SymbolId, to: SymbolId, confidence: f32) {
279        let Some(&from_idx) = self.symbol_to_index.get(&from) else {
280            return;
281        };
282        let Some(&to_idx) = self.symbol_to_index.get(&to) else {
283            return;
284        };
285
286        self.add_edge_internal(from_idx, to_idx, EdgeKind::Reference, confidence);
287    }
288
289    /// Internal method to add an edge with deduplication and confidence.
290    ///
291    /// If a duplicate `(source, target, kind)` already exists, the existing
292    /// edge's confidence is bumped to `max(existing, new)` - never reduced.
293    fn add_edge_internal(
294        &mut self,
295        source: NodeIndex,
296        target: NodeIndex,
297        kind: EdgeKind,
298        confidence: f32,
299    ) {
300        self.insert_edge(source, target, kind, confidence, None);
301    }
302
303    /// Returns the FileId for a given file path, if registered.
304    pub fn file_id_for_path(&self, path: &std::path::PathBuf) -> Option<FileId> {
305        self.path_to_file.get(path).copied()
306    }
307
308    /// Builds and returns an adjacency map from FileId to the FileIds it imports.
309    ///
310    /// Walks all Import edges in the graph to produce the relationship.
311    pub fn import_adjacency(&self) -> HashMap<FileId, Vec<FileId>> {
312        let mut adjacency: HashMap<FileId, Vec<FileId>> = HashMap::new();
313        let mut index_to_file: HashMap<NodeIndex, FileId> = HashMap::new();
314        for (&file_id, &idx) in &self.file_to_index {
315            index_to_file.insert(idx, file_id);
316        }
317        for edge in self.graph.edge_references() {
318            if edge.weight().kind == EdgeKind::Import
319                && let (Some(&from_id), Some(&to_id)) = (
320                    index_to_file.get(&edge.source()),
321                    index_to_file.get(&edge.target()),
322                )
323            {
324                adjacency.entry(from_id).or_default().push(to_id);
325            }
326        }
327        adjacency
328    }
329
330    /// Finalizes the graph and returns the constructed CodeGraph.
331    pub fn build(self) -> CodeGraph {
332        CodeGraph::from_parts(
333            self.graph,
334            self.edge_index,
335            self.file_to_index,
336            self.symbol_to_index,
337            self.external_index,
338            self.snapshot_id,
339        )
340    }
341
342    /// Returns the number of nodes in the graph so far.
343    pub fn node_count(&self) -> usize {
344        self.graph.node_count()
345    }
346
347    /// Returns the number of edges in the graph so far.
348    pub fn edge_count(&self) -> usize {
349        self.graph.edge_count()
350    }
351
352    /// Returns the number of external dependency nodes.
353    pub fn external_count(&self) -> usize {
354        self.external_index.len()
355    }
356
357    /// Assemble a complete CodeGraph + SCC from parallel extraction results.
358    ///
359    /// Replaces the manual multi-step wiring in pipeline.rs. Handles:
360    /// - File and symbol node registration
361    /// - Import edge resolution (via language-specific resolvers)
362    /// - Cross-file reference resolution via FlattenedScopeCache
363    /// - SCC analysis
364    ///
365    /// Errors during symbol addition are non-fatal and appended to `diagnostics`.
366    pub fn from_extractions<F>(
367        extractions: &[F],
368        root: &std::path::Path,
369        snapshot_id: crate::model::SnapshotId,
370        diagnostics: &mut Vec<crate::error::Diagnostic>,
371    ) -> (CodeGraph, crate::graph::SccAnalysis)
372    where
373        F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
374    {
375        let (graph, scc, _) =
376            Self::from_extractions_with_scope(extractions, root, snapshot_id, diagnostics);
377        (graph, scc)
378    }
379
380    /// Build the graph, the SCC, the scope cache, and the resolved references.
381    pub fn from_extractions_with_scope<F>(
382        extractions: &[F],
383        root: &std::path::Path,
384        snapshot_id: crate::model::SnapshotId,
385        diagnostics: &mut Vec<crate::error::Diagnostic>,
386    ) -> (
387        CodeGraph,
388        crate::graph::SccAnalysis,
389        crate::graph::resolver::FlattenedScopeCache,
390    )
391    where
392        F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
393    {
394        let parts = Self::from_extractions_detailed(extractions, root, snapshot_id, diagnostics);
395        (parts.graph, parts.scc, parts.scope)
396    }
397
398    /// Build everything one analysis pass produces from a set of extractions.
399    ///
400    /// Stages: file and symbol node registration, import edge resolution, cross
401    /// file reference resolution with the flattened scope cache, client-call
402    /// edge injection, and SCC analysis. Errors during symbol addition are
403    /// non-fatal and appended to `diagnostics`. Both the one-shot and the
404    /// incremental entry point call this, so the analysis of a tree cannot
405    /// depend on which entry point produced it.
406    pub fn from_extractions_detailed<F>(
407        extractions: &[F],
408        root: &std::path::Path,
409        snapshot_id: crate::model::SnapshotId,
410        diagnostics: &mut Vec<crate::error::Diagnostic>,
411    ) -> AnalysisParts
412    where
413        F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
414    {
415        let mut builder = Self::new(snapshot_id);
416
417        register_files(&mut builder, extractions);
418        register_symbols(&mut builder, extractions, diagnostics);
419        #[cfg(feature = "dataflow")]
420        register_dataflow(&mut builder, extractions);
421
422        let path_to_file_id = path_to_file_id(&builder, extractions);
423        resolve_imports(
424            &mut builder,
425            extractions,
426            root,
427            &path_to_file_id,
428            diagnostics,
429        );
430        let (scope_cache, references) =
431            resolve_references(&mut builder, extractions, &path_to_file_id, diagnostics);
432
433        let graph = builder.build();
434        #[cfg(feature = "metacall-deploy")]
435        let mut graph = graph;
436        #[cfg(feature = "metacall-deploy")]
437        inject_client_call_edges(&mut graph, extractions, root, diagnostics);
438
439        let scc = crate::graph::SccAnalysis::analyze(graph.graph());
440
441        AnalysisParts {
442            graph,
443            scc,
444            scope: scope_cache,
445            references,
446        }
447    }
448}
449
450/// Everything one analysis pass produces from a set of extractions.
451#[derive(Debug)]
452pub struct AnalysisParts {
453    pub graph: CodeGraph,
454    pub scc: crate::graph::SccAnalysis,
455    /// Flattened scope cache used to resolve a name from a cursor position.
456    pub scope: crate::graph::resolver::FlattenedScopeCache,
457    /// One record per resolved use site, in extraction and reference order.
458    pub references: Vec<crate::graph::resolver::ResolvedReference>,
459}
460/// Registers every file as a node.
461fn register_files<F>(builder: &mut GraphBuilder, extractions: &[F])
462where
463    F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
464{
465    for file in extractions {
466        let file = file.borrow();
467        builder.add_file(file.path.clone(), file.lang);
468    }
469}
470
471/// Registers every symbol and its ownership edge.
472///
473/// A symbol whose file was not registered is a warning: the extraction order
474/// broke, and the rest of the graph is still usable.
475fn register_symbols<F>(
476    builder: &mut GraphBuilder,
477    extractions: &[F],
478    diagnostics: &mut Vec<crate::error::Diagnostic>,
479) where
480    F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
481{
482    for file in extractions {
483        let file = file.borrow();
484        for symbol in &file.symbols {
485            if let Err(error) = builder.add_symbol(symbol) {
486                diagnostics.push(crate::error::Diagnostic {
487                    path: file.path.clone(),
488                    severity: crate::error::Severity::Warning,
489                    message: format!("failed to add symbol to graph: {error}"),
490                    source_range: None,
491                });
492            }
493        }
494    }
495}
496
497/// Registers data nodes first, then the flow edges that reference them.
498#[cfg(feature = "dataflow")]
499fn register_dataflow<F>(builder: &mut GraphBuilder, extractions: &[F])
500where
501    F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
502{
503    for file in extractions {
504        let file = file.borrow();
505        for data_node in &file.data_nodes {
506            builder.add_data_node(data_node);
507        }
508    }
509    for file in extractions {
510        let file = file.borrow();
511        for flow_edge in &file.flow_edges {
512            builder.add_flow_edge(
513                flow_edge.source,
514                flow_edge.target,
515                flow_edge.kind,
516                flow_edge.confidence,
517            );
518        }
519    }
520}
521
522/// Maps every registered file back to its identifier, for the resolver.
523fn path_to_file_id<F>(
524    builder: &GraphBuilder,
525    extractions: &[F],
526) -> HashMap<std::path::PathBuf, crate::model::FileId>
527where
528    F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
529{
530    extractions
531        .iter()
532        .filter_map(|file| {
533            let file = file.borrow();
534            builder
535                .file_id_for_path(&file.path)
536                .map(|id| (file.path.clone(), id))
537        })
538        .collect()
539}
540
541/// Resolves every import specifier and adds the import edges.
542///
543/// A specifier no resolver resolves becomes an external node, unless it is
544/// relative: a relative import that does not resolve is a warning, because the
545/// file it names is missing from the tree.
546fn resolve_imports<F>(
547    builder: &mut GraphBuilder,
548    extractions: &[F],
549    root: &std::path::Path,
550    path_to_file_id: &HashMap<std::path::PathBuf, crate::model::FileId>,
551    diagnostics: &mut Vec<crate::error::Diagnostic>,
552) where
553    F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
554{
555    let mut resolvers = HashMap::new();
556    for lang in crate::language::LangId::all() {
557        resolvers.insert(lang, crate::language::import_resolver::make_resolver(lang));
558    }
559
560    for file in extractions {
561        let file = file.borrow();
562        let Some(&source_id) = path_to_file_id.get(&file.path) else {
563            continue;
564        };
565        let source_dir = file
566            .path
567            .parent()
568            .unwrap_or_else(|| std::path::Path::new("."));
569        let Some(resolver) = resolvers.get(&file.lang) else {
570            continue;
571        };
572        for import in &file.imports {
573            let specifier = import_specifier_for(file.lang, import);
574            match resolver.resolve(&specifier, source_dir, root) {
575                Some(target) => builder.add_import(source_id, target),
576                None if is_relative_specifier(&specifier) => {
577                    diagnostics.push(crate::error::Diagnostic {
578                        path: file.path.clone(),
579                        severity: crate::error::Severity::Warning,
580                        message: format!("unresolved relative import: {specifier}"),
581                        source_range: Some(import.range.clone()),
582                    });
583                }
584                None => builder.add_import(
585                    source_id,
586                    std::path::PathBuf::from(external_name(file.lang, &specifier)),
587                ),
588            }
589        }
590    }
591}
592
593/// Resolves cross-file references and returns the scope cache they were
594/// resolved against.
595fn resolve_references<F>(
596    builder: &mut GraphBuilder,
597    extractions: &[F],
598    path_to_file_id: &HashMap<std::path::PathBuf, crate::model::FileId>,
599    diagnostics: &mut Vec<crate::error::Diagnostic>,
600) -> (
601    crate::graph::resolver::FlattenedScopeCache,
602    Vec<crate::graph::resolver::ResolvedReference>,
603)
604where
605    F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
606{
607    let import_adjacency = builder.import_adjacency();
608    let context = crate::graph::resolver::ResolutionContext::from_extractions(
609        extractions,
610        path_to_file_id,
611        import_adjacency,
612    );
613    let scope_cache = crate::graph::resolver::FlattenedScopeCache::build(&context, diagnostics);
614    let references = crate::graph::resolver::resolve_references_detailed(
615        extractions,
616        path_to_file_id,
617        &scope_cache,
618        diagnostics,
619    );
620    for (from, to, confidence) in crate::graph::resolver::reference_edges(&references) {
621        builder.add_reference(from, to, confidence);
622    }
623    (scope_cache, references)
624}
625
626/// Adds the client-call projections as ordinary reference edges.
627///
628/// Resolution needs the file and symbol nodes, so this runs after the graph is
629/// built. Navigation and SCC see these edges like any other reference.
630#[cfg(feature = "metacall-deploy")]
631fn inject_client_call_edges<F>(
632    graph: &mut CodeGraph,
633    extractions: &[F],
634    root: &std::path::Path,
635    diagnostics: &mut Vec<crate::error::Diagnostic>,
636) where
637    F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
638{
639    let call_sites: Vec<crate::deploy::scanner::CallSite> = extractions
640        .iter()
641        .flat_map(|file| file.borrow().call_sites.iter().cloned())
642        .collect();
643    if call_sites.is_empty() {
644        return;
645    }
646    let projections = crate::deploy::client_call::resolve_client_call_projections(
647        graph,
648        extractions,
649        &call_sites,
650        root,
651    );
652    diagnostics.extend(projections.diagnostics);
653    for (from, to, confidence) in projections.file_edges {
654        graph.add_edge_normalized(from, to, EdgeKind::Reference, confidence);
655    }
656    for (from, to, confidence) in projections.symbol_edges {
657        if let (Some(from_idx), Some(to_idx)) =
658            (graph.symbol_node_index(from), graph.symbol_node_index(to))
659        {
660            graph.add_edge_normalized(from_idx, to_idx, EdgeKind::Reference, confidence);
661        }
662    }
663}
664
665/// The specifier the resolver sees.
666///
667/// Python spells a bare relative import (`from . import x`) as a package plus
668/// a name, so the name joins the module path before resolution.
669fn import_specifier_for<'a>(
670    lang: crate::language::LangId,
671    import: &'a crate::model::UnresolvedImport,
672) -> std::borrow::Cow<'a, str> {
673    if lang == crate::language::LangId::Python {
674        crate::language::python::normalize_relative_specifier(
675            &import.import_specifier,
676            import.symbol.as_deref(),
677            import.star,
678        )
679    } else {
680        std::borrow::Cow::Borrowed(import.import_specifier.as_str())
681    }
682}
683
684/// A relative specifier addresses the project, so a miss is a config error.
685fn is_relative_specifier(specifier: &str) -> bool {
686    let stripped = specifier.trim_matches(|c| c == '\'' || c == '"');
687    stripped.starts_with('.') || stripped.starts_with('/')
688}
689
690/// Node name for an unresolved non-relative import (ADR 0003).
691fn external_name(lang: crate::language::LangId, specifier: &str) -> String {
692    use crate::language::{LangId, import_resolver};
693    let name = match lang {
694        LangId::C | LangId::Cpp => import_resolver::strip_c_family_quotes(specifier),
695        _ => import_resolver::strip_import_quotes(specifier),
696    };
697    name.to_string()
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703    use crate::model::{LineColumn, SourceRange, SymbolKind, Visibility};
704
705    fn test_source_range() -> SourceRange {
706        SourceRange {
707            byte_start: 0,
708            byte_end: 10,
709            start: LineColumn { line: 0, column: 0 },
710            end: LineColumn {
711                line: 0,
712                column: 10,
713            },
714        }
715    }
716
717    fn test_symbol(id: u32, name: &str, _file_id: u32) -> Symbol {
718        Symbol {
719            id: SymbolId::new(id).unwrap(),
720            name: name.to_string(),
721            kind: SymbolKind::Function,
722            language: LangId::Python,
723            file_path: PathBuf::from("test.py"),
724            source_range: test_source_range(),
725            name_range: None,
726            visibility: Some(Visibility::Public),
727            signature: None,
728            docstring: None,
729            is_async: false,
730        }
731    }
732
733    #[test]
734    fn builder_creates_file_nodes() {
735        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
736        let path = PathBuf::from("src/main.py");
737
738        let id1 = builder.add_file(path.clone(), LangId::Python);
739        let id2 = builder.add_file(path, LangId::Python);
740
741        assert_eq!(id1, id2, "same path should return same FileId");
742        assert_eq!(builder.node_count(), 1);
743    }
744
745    #[test]
746    fn builder_creates_symbol_with_ownership() {
747        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
748        let path = PathBuf::from("test.py");
749
750        let file_id = builder.add_file(path.clone(), LangId::Python);
751        let symbol = test_symbol(1, "hello", file_id.to_raw());
752
753        let _sym_idx = builder.add_symbol(&symbol).unwrap();
754
755        assert_eq!(builder.node_count(), 2); // file + symbol
756        assert_eq!(builder.edge_count(), 1); // ownership edge
757    }
758
759    #[test]
760    fn builder_deduplicates_edges() {
761        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
762        let path1 = PathBuf::from("a.py");
763        let path2 = PathBuf::from("b.py");
764
765        let file1 = builder.add_file(path1, LangId::Python);
766        let _file2 = builder.add_file(path2, LangId::Python);
767
768        // Add same import twice
769        builder.add_import(file1, PathBuf::from("b.py"));
770        builder.add_import(file1, PathBuf::from("b.py"));
771
772        assert_eq!(
773            builder.edge_count(),
774            1,
775            "duplicate edges should be deduplicated"
776        );
777    }
778
779    #[test]
780    fn builder_reference_max_merges_confidence() {
781        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
782        builder.add_file(PathBuf::from("test.py"), LangId::Python);
783        let low = test_symbol(10, "low_fn", 0);
784        let high = test_symbol(11, "high_fn", 0);
785        builder.add_symbol(&low).unwrap();
786        builder.add_symbol(&high).unwrap();
787
788        builder.add_reference(low.id, high.id, 0.5);
789        builder.add_reference(low.id, high.id, 0.9);
790        assert_eq!(builder.edge_count(), 3);
791
792        let graph = builder.build();
793        assert_eq!(graph.edge_count(), 3);
794        let refs: Vec<_> = graph.edges_of_kind(EdgeKind::Reference).collect();
795        assert_eq!(refs.len(), 1);
796        let (src, dst) = refs[0];
797        let edge = graph.graph().edges_connecting(src, dst).next().unwrap();
798        assert_eq!(edge.weight().confidence, 0.9);
799    }
800
801    #[test]
802    fn builder_creates_external_node_for_unknown_import() {
803        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
804        let path = PathBuf::from("main.py");
805
806        let file_id = builder.add_file(path, LangId::Python);
807
808        // Import of external module not in our project
809        builder.add_import(file_id, PathBuf::from("external_module.py"));
810
811        // Should create an external node and import edge
812        assert_eq!(
813            builder.edge_count(),
814            1,
815            "external import should create an edge"
816        );
817        assert_eq!(builder.external_count(), 1, "should have one external node");
818    }
819
820    #[test]
821    fn builder_tracks_node_mappings() {
822        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
823        let path = PathBuf::from("test.py");
824
825        let file_id = builder.add_file(path, LangId::Python);
826        let symbol = test_symbol(42, "func", file_id.to_raw());
827
828        builder.add_symbol(&symbol).unwrap();
829
830        // Verify mappings exist
831        assert!(builder.file_to_index.contains_key(&file_id));
832        assert!(
833            builder
834                .symbol_to_index
835                .contains_key(&SymbolId::new(42).unwrap())
836        );
837    }
838
839    #[test]
840    fn from_extractions_builds_graph_with_correct_node_count() {
841        use crate::model::{FileExtraction, LineColumn, SourceRange, Symbol, SymbolId, SymbolKind};
842        use std::path::PathBuf;
843        let sym = Symbol {
844            id: SymbolId::new(1).unwrap(),
845            name: "foo".into(),
846            kind: SymbolKind::Function,
847            language: LangId::Python,
848            file_path: PathBuf::from("/proj/a.py"),
849            source_range: SourceRange {
850                byte_start: 0,
851                byte_end: 10,
852                start: LineColumn { line: 0, column: 0 },
853                end: LineColumn {
854                    line: 0,
855                    column: 10,
856                },
857            },
858            name_range: None,
859            visibility: None,
860            signature: None,
861            docstring: None,
862            is_async: false,
863        };
864        let mut base = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
865        base.symbols = vec![sym];
866        let extractions = vec![base];
867        let mut diags = Vec::new();
868        let (graph, _scc) = GraphBuilder::from_extractions(
869            &extractions,
870            std::path::Path::new("/proj"),
871            SnapshotId::new(1).unwrap(),
872            &mut diags,
873        );
874        assert_eq!(graph.file_count(), 1);
875        assert_eq!(graph.symbol_count(), 1);
876        assert!(diags.is_empty());
877    }
878
879    #[test]
880    fn from_extractions_populates_scc_analysis() {
881        use crate::model::FileExtraction;
882        let extractions: Vec<FileExtraction> = vec![];
883        let mut diags = Vec::new();
884        let (graph, scc) = GraphBuilder::from_extractions(
885            &extractions,
886            std::path::Path::new("/proj"),
887            SnapshotId::new(1).unwrap(),
888            &mut diags,
889        );
890        assert_eq!(graph.node_count(), 0);
891        assert!(!scc.components.iter().any(|c| c.is_cyclic));
892    }
893
894    #[test]
895    fn from_extractions_accumulates_diagnostics_on_symbol_error() {
896        use crate::model::{FileExtraction, LineColumn, SourceRange, Symbol, SymbolId, SymbolKind};
897        use std::path::PathBuf;
898        let sym = Symbol {
899            id: SymbolId::new(99).unwrap(),
900            name: "orphan".into(),
901            kind: SymbolKind::Function,
902            language: LangId::Python,
903            file_path: PathBuf::from("/proj/missing.py"),
904            source_range: SourceRange {
905                byte_start: 0,
906                byte_end: 5,
907                start: LineColumn { line: 0, column: 0 },
908                end: LineColumn { line: 0, column: 5 },
909            },
910            name_range: None,
911            visibility: None,
912            signature: None,
913            docstring: None,
914            is_async: false,
915        };
916        let mut base = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
917        base.symbols = vec![sym];
918        let extractions = vec![base];
919        let mut diags = Vec::new();
920        let (_graph, _scc) = GraphBuilder::from_extractions(
921            &extractions,
922            std::path::Path::new("/proj"),
923            SnapshotId::new(1).unwrap(),
924            &mut diags,
925        );
926        assert!(!diags.is_empty(), "expected diagnostic for orphan symbol");
927    }
928
929    #[test]
930    fn from_extractions_resolves_cross_file_imports() {
931        use crate::model::{FileExtraction, LineColumn, SourceRange, UnresolvedImport};
932        use std::path::PathBuf;
933        let mut first = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
934        first.imports = vec![UnresolvedImport {
935            import_specifier: "b".into(),
936            alias: None,
937            symbol: None,
938            star: false,
939            range: SourceRange {
940                byte_start: 0,
941                byte_end: 1,
942                start: LineColumn { line: 0, column: 0 },
943                end: LineColumn { line: 0, column: 1 },
944            },
945        }];
946        let second = FileExtraction::empty(PathBuf::from("/proj/b.py"), LangId::Python);
947        let extractions = vec![first, second];
948        let mut diags = Vec::new();
949        let (graph, _scc) = GraphBuilder::from_extractions(
950            &extractions,
951            std::path::Path::new("/proj"),
952            SnapshotId::new(1).unwrap(),
953            &mut diags,
954        );
955        assert_eq!(graph.file_count(), 2);
956        let import_edges: Vec<_> = graph
957            .edges_of_kind(crate::graph::EdgeKind::Import)
958            .collect();
959        assert_eq!(
960            import_edges.len(),
961            1,
962            "expected import edge from a.py to b.py"
963        );
964    }
965
966    #[cfg(feature = "dataflow")]
967    #[test]
968    fn add_data_node_creates_ownership_edge_to_symbol() {
969        use crate::model::{DataNode, DataNodeId, DataScope};
970        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
971        let file_path = PathBuf::from("test.rs");
972        let _file_id = builder.add_file(file_path.clone(), LangId::Rust);
973        let sym = Symbol {
974            id: SymbolId::new(1).unwrap(),
975            name: "my_fn".into(),
976            kind: SymbolKind::Function,
977            language: LangId::Rust,
978            file_path,
979            source_range: test_source_range(),
980            name_range: None,
981            visibility: Some(Visibility::Public),
982            signature: None,
983            docstring: None,
984            is_async: false,
985        };
986        let sym_idx = builder.add_symbol(&sym).unwrap();
987
988        let data = DataNode {
989            id: DataNodeId::new(1).unwrap(),
990            symbol_id: Some(SymbolId::new(1).unwrap()),
991            name: Some("x".into()),
992            scope: DataScope::Local,
993            type_hint: Some("i32".into()),
994            source_range: test_source_range(),
995        };
996        builder.add_data_node(&data);
997
998        let graph = builder.build();
999        let ownership_edges: Vec<_> = graph.edges_of_kind(EdgeKind::Ownership).collect();
1000        assert_eq!(
1001            ownership_edges.len(),
1002            2,
1003            "expected file→symbol and symbol→data ownership edges"
1004        );
1005
1006        let data_ownership_edges: Vec<_> = ownership_edges
1007            .into_iter()
1008            .filter(|(src, _)| *src == sym_idx)
1009            .collect();
1010        assert!(
1011            !data_ownership_edges.is_empty(),
1012            "expected symbol→data ownership edge"
1013        );
1014    }
1015    /// Stage: registration. Files first, then each symbol owns its file node.
1016    #[test]
1017    fn registration_stage_adds_files_and_symbol_ownership() {
1018        use crate::model::FileExtraction;
1019        let mut file = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
1020        file.symbols = vec![symbol_for(1, "alpha", std::path::Path::new("/proj/a.py"))];
1021        let extractions = vec![file];
1022
1023        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
1024        let mut diagnostics = Vec::new();
1025        register_files(&mut builder, &extractions);
1026        register_symbols(&mut builder, &extractions, &mut diagnostics);
1027
1028        assert!(diagnostics.is_empty());
1029        assert_eq!(builder.node_count(), 2, "one file node and one symbol node");
1030        assert_eq!(builder.edge_count(), 1, "the symbol owns its file");
1031    }
1032
1033    /// Stage: dataflow. Nodes are registered before the edges that join them.
1034    #[cfg(feature = "dataflow")]
1035    #[test]
1036    fn dataflow_stage_registers_nodes_and_flow_edges() {
1037        use crate::model::{DataNodeId, FileExtraction, FlowEdge, FlowKind};
1038        let mut file = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
1039        file.symbols = vec![symbol_for(1, "alpha", std::path::Path::new("/proj/a.py"))];
1040        file.data_nodes = vec![data_node(1, "x"), data_node(2, "y")];
1041        file.flow_edges = vec![FlowEdge {
1042            source: DataNodeId::new(1).unwrap(),
1043            target: DataNodeId::new(2).unwrap(),
1044            kind: FlowKind::Argument,
1045            confidence: 0.8,
1046        }];
1047        let extractions = vec![file];
1048
1049        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
1050        register_files(&mut builder, &extractions);
1051        register_symbols(&mut builder, &extractions, &mut Vec::new());
1052        register_dataflow(&mut builder, &extractions);
1053
1054        assert_eq!(builder.node_count(), 4, "file, symbol and two data nodes");
1055        let graph = builder.build();
1056        assert_eq!(graph.edges_of_kind(EdgeKind::Flow).count(), 1);
1057    }
1058
1059    /// Stage: imports. A project import joins two files, an unresolved
1060    /// specifier becomes an external node.
1061    #[test]
1062    fn import_stage_resolves_project_and_external_targets() {
1063        use crate::model::FileExtraction;
1064        // The Python resolver checks the file system, so the project is real.
1065        let project = tempfile::tempdir().unwrap();
1066        let root = project.path();
1067        std::fs::write(root.join("a.py"), "import b\nimport requests\n").unwrap();
1068        std::fs::write(root.join("b.py"), "def beta(): pass\n").unwrap();
1069
1070        let mut first = FileExtraction::empty(root.join("a.py"), LangId::Python);
1071        first.imports = vec![import("b"), import("requests")];
1072        let second = FileExtraction::empty(root.join("b.py"), LangId::Python);
1073        let extractions = vec![first, second];
1074
1075        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
1076        register_files(&mut builder, &extractions);
1077        let file_ids = path_to_file_id(&builder, &extractions);
1078        let mut diagnostics = Vec::new();
1079        resolve_imports(
1080            &mut builder,
1081            &extractions,
1082            root,
1083            &file_ids,
1084            &mut diagnostics,
1085        );
1086
1087        assert!(diagnostics.is_empty(), "both imports resolve");
1088        let graph = builder.build();
1089        assert_eq!(graph.edges_of_kind(EdgeKind::Import).count(), 2);
1090        assert_eq!(graph.external_count(), 1, "requests is an external node");
1091    }
1092
1093    /// Stage: references. The returned cache resolves the imported name, and
1094    /// the edge reaches the graph.
1095    #[test]
1096    fn reference_stage_returns_a_usable_scope_cache() {
1097        use crate::model::{FileExtraction, UnresolvedReference};
1098        let project = tempfile::tempdir().unwrap();
1099        let root = project.path();
1100        let first_path = root.join("a.py");
1101        let second_path = root.join("b.py");
1102        std::fs::write(&first_path, "def alpha(): pass\n").unwrap();
1103        std::fs::write(&second_path, "import a\ndef beta(): return alpha()\n").unwrap();
1104
1105        let mut first = FileExtraction::empty(first_path.clone(), LangId::Python);
1106        first.symbols = vec![symbol_for(1, "alpha", &first_path)];
1107        let mut second = FileExtraction::empty(second_path.clone(), LangId::Python);
1108        second.symbols = vec![symbol_for(2, "beta", &second_path)];
1109        second.imports = vec![import("a")];
1110        second.references = vec![UnresolvedReference {
1111            name: "alpha".to_string(),
1112            range: test_source_range(),
1113        }];
1114        let extractions = vec![first, second];
1115
1116        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
1117        register_files(&mut builder, &extractions);
1118        register_symbols(&mut builder, &extractions, &mut Vec::new());
1119        let file_ids = path_to_file_id(&builder, &extractions);
1120        let mut diagnostics = Vec::new();
1121        resolve_imports(
1122            &mut builder,
1123            &extractions,
1124            root,
1125            &file_ids,
1126            &mut diagnostics,
1127        );
1128        let (scope, _references) =
1129            resolve_references(&mut builder, &extractions, &file_ids, &mut diagnostics);
1130
1131        assert_eq!(scope.iter_scopes().count(), 2);
1132        let graph = builder.build();
1133        assert_eq!(
1134            graph.edges_of_kind(EdgeKind::Reference).count(),
1135            1,
1136            "beta references alpha"
1137        );
1138    }
1139
1140    /// Stage: client calls. Resolution runs after the graph exists, and the
1141    /// call projects onto the symbol that encloses it.
1142    #[cfg(feature = "metacall-deploy")]
1143    #[test]
1144    fn client_call_stage_projects_the_call_onto_its_symbol() {
1145        use crate::deploy::scanner::CallSite;
1146        use crate::model::FileExtraction;
1147        let mut caller =
1148            FileExtraction::empty(PathBuf::from("/proj/orchestrator.py"), LangId::Python);
1149        caller.symbols = vec![symbol_for(
1150            1,
1151            "compute",
1152            std::path::Path::new("/proj/orchestrator.py"),
1153        )];
1154        caller.call_sites = vec![CallSite::call(
1155            PathBuf::from("/proj/orchestrator.py"),
1156            LangId::Python,
1157            "multiply".to_string(),
1158            false,
1159            Some(test_source_range()),
1160            1.0,
1161        )];
1162        let mut callee = FileExtraction::empty(PathBuf::from("/proj/math.js"), LangId::JavaScript);
1163        callee.symbols = vec![symbol_for(
1164            2,
1165            "multiply",
1166            std::path::Path::new("/proj/math.js"),
1167        )];
1168        let extractions = vec![caller, callee];
1169
1170        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
1171        register_files(&mut builder, &extractions);
1172        register_symbols(&mut builder, &extractions, &mut Vec::new());
1173        let mut graph = builder.build();
1174        let mut diagnostics = Vec::new();
1175        inject_client_call_edges(
1176            &mut graph,
1177            &extractions,
1178            std::path::Path::new("/proj"),
1179            &mut diagnostics,
1180        );
1181
1182        assert!(
1183            graph.reference_edges().count() >= 1,
1184            "the invocation must project onto its symbol"
1185        );
1186    }
1187
1188    fn symbol_for(id: u32, name: &str, file_path: &std::path::Path) -> Symbol {
1189        Symbol {
1190            id: SymbolId::new(id).unwrap(),
1191            name: name.to_string(),
1192            kind: SymbolKind::Function,
1193            language: LangId::Python,
1194            file_path: file_path.to_path_buf(),
1195            source_range: test_source_range(),
1196            name_range: None,
1197            visibility: Some(Visibility::Public),
1198            signature: None,
1199            docstring: None,
1200            is_async: false,
1201        }
1202    }
1203
1204    fn import(specifier: &str) -> crate::model::UnresolvedImport {
1205        crate::model::UnresolvedImport {
1206            import_specifier: specifier.to_string(),
1207            alias: None,
1208            symbol: None,
1209            star: false,
1210            range: test_source_range(),
1211        }
1212    }
1213
1214    #[cfg(feature = "dataflow")]
1215    fn data_node(id: u32, name: &str) -> crate::model::DataNode {
1216        use crate::model::{DataNodeId, DataScope};
1217        crate::model::DataNode {
1218            id: DataNodeId::new(id).unwrap(),
1219            symbol_id: None,
1220            name: Some(name.to_string()),
1221            scope: DataScope::Local,
1222            type_hint: None,
1223            source_range: test_source_range(),
1224        }
1225    }
1226}