Skip to main content

sqry_classpath/graph/
emitter.rs

1//! Emit classpath nodes and edges into the sqry unified graph.
2//!
3//! This module takes a [`ClasspathIndex`] (produced by bytecode parsing) and
4//! emits synthetic graph nodes and edges into a [`StagingGraph`]. Each JAR gets
5//! a synthetic [`FileId`] via [`FileRegistry::register_external()`], and each
6//! class/method/field becomes a graph node with zero-span metadata (since the
7//! data comes from bytecode, not source).
8//!
9//! ## Emission pipeline
10//!
11//! 1. **File registration** - For each unique JAR path, register a synthetic
12//!    `FileEntry` with path convention `{jar_path}!/{fqn}.class`.
13//! 2. **Node emission** - For each `ClassStub`, emit nodes for the class itself,
14//!    its methods, fields, annotations, type parameters, enum constants, lambda
15//!    targets, and module declarations.
16//! 3. **Structural edges** - Class→Method (`Defines`), Class→Field (`Defines`),
17//!    Class→InnerClass (`Contains`).
18//! 4. **Metadata attachment** - `ClasspathNodeMetadata` on all emitted nodes
19//!    (class, method, field, enum constant, type parameter, lambda target,
20//!    module).
21//!
22//! After emission, the returned `FQN→NodeId` mapping is used by
23//! [`create_classpath_edges`] for cross-reference edge creation.
24
25use std::collections::HashMap;
26use std::path::{Path, PathBuf};
27
28use log::debug;
29use sqry_core::graph::node::Language;
30use sqry_core::graph::unified::build::StagingGraph;
31use sqry_core::graph::unified::concurrent::CodeGraph;
32use sqry_core::graph::unified::edge::EdgeKind;
33use sqry_core::graph::unified::node::NodeKind;
34use sqry_core::graph::unified::storage::metadata::{
35    ClasspathNodeMetadata, NodeMetadataStore, TypedMetadata,
36};
37use sqry_core::graph::unified::storage::registry::FileRegistry;
38use sqry_core::graph::unified::storage::{NodeEntry, StringInterner};
39use sqry_core::graph::unified::{FileId, NodeId, StringId};
40
41use crate::stub::index::ClasspathIndex;
42use crate::stub::model::{ClassKind, ClassStub};
43use crate::{ClasspathError, ClasspathResult};
44
45use super::provenance::ClasspathProvenance;
46
47// ---------------------------------------------------------------------------
48// String interning helper
49// ---------------------------------------------------------------------------
50
51/// Helper to intern strings via `StringInterner` and track the resulting IDs.
52///
53/// Unlike `GraphBuildHelper` which uses staging-local IDs, the classpath emitter
54/// interns directly into the `StringInterner` because classpath emission happens
55/// as a discrete phase before the per-file build pipeline. The resulting global
56/// `StringId`s are used directly in `NodeEntry` fields.
57struct InternHelper<'a> {
58    interner: &'a mut StringInterner,
59    cache: HashMap<String, StringId>,
60}
61
62impl<'a> InternHelper<'a> {
63    fn new(interner: &'a mut StringInterner) -> Self {
64        Self {
65            interner,
66            cache: HashMap::new(),
67        }
68    }
69
70    /// Intern a string, returning a global `StringId`.
71    ///
72    /// Caches results to avoid repeated lookups for common strings like
73    /// visibility modifiers.
74    fn intern(&mut self, s: &str) -> ClasspathResult<StringId> {
75        if let Some(&id) = self.cache.get(s) {
76            return Ok(id);
77        }
78        let id = self.interner.intern(s).map_err(|e| {
79            ClasspathError::EmissionError(format!("string intern failed for '{s}': {e}"))
80        })?;
81        self.cache.insert(s.to_owned(), id);
82        Ok(id)
83    }
84}
85
86// ---------------------------------------------------------------------------
87// Visibility mapping
88// ---------------------------------------------------------------------------
89
90/// Map JVM access flags to a visibility string for the graph.
91#[allow(clippy::trivially_copy_pass_by_ref)] // API consistency with other methods
92fn access_to_visibility(access: &crate::stub::model::AccessFlags) -> &'static str {
93    if access.is_public() {
94        "public"
95    } else if access.is_protected() {
96        "protected"
97    } else if access.is_private() {
98        "private"
99    } else {
100        "package"
101    }
102}
103
104/// Map `ClassKind` to the corresponding `NodeKind`.
105fn class_kind_to_node_kind(kind: ClassKind) -> NodeKind {
106    match kind {
107        ClassKind::Class | ClassKind::Record => NodeKind::Class,
108        ClassKind::Interface => NodeKind::Interface,
109        ClassKind::Enum => NodeKind::Enum,
110        ClassKind::Annotation => NodeKind::Annotation,
111        ClassKind::Module => NodeKind::JavaModule,
112    }
113}
114
115// ---------------------------------------------------------------------------
116// File ID management
117// ---------------------------------------------------------------------------
118
119/// Register a synthetic external file for a class within a JAR.
120///
121/// Path convention: `{jar_path}!/{fqn}.class` (similar to Java URL conventions
122/// for JAR entries like `jar:file:///path.jar!/com/example/Foo.class`).
123fn register_synthetic_file(
124    jar_path: &Path,
125    fqn: &str,
126    file_registry: &mut FileRegistry,
127) -> ClasspathResult<FileId> {
128    let class_path_str = fqn.replace('.', "/");
129    let synthetic_path = format!("{}!/{class_path_str}.class", jar_path.display());
130    let path = PathBuf::from(&synthetic_path);
131    file_registry
132        .register_external(&path, Some(Language::Java))
133        .map_err(|e| {
134            ClasspathError::EmissionError(format!(
135                "failed to register synthetic file for {fqn}: {e}"
136            ))
137        })
138}
139
140// ---------------------------------------------------------------------------
141// Node emission
142// ---------------------------------------------------------------------------
143
144/// Emit all classpath nodes and edges into a staging graph.
145///
146/// Returns the mapping of FQN to `NodeId` for cross-reference edge creation.
147///
148/// # Arguments
149///
150/// * `index` - The merged classpath index containing all parsed class stubs.
151/// * `staging` - The staging graph to emit nodes and edges into.
152/// * `file_registry` - File registry for synthetic file registration.
153/// * `interner` - String interner for name/qualifier interning.
154/// * `metadata_store` - Metadata store for classpath provenance attachment.
155/// * `provenance` - Provenance information for each JAR.
156///
157/// # Errors
158///
159/// Returns `ClasspathError::EmissionError` if string interning or file
160/// registration fails.
161#[allow(clippy::too_many_lines)]
162pub fn emit_classpath_nodes(
163    index: &ClasspathIndex,
164    staging: &mut StagingGraph,
165    file_registry: &mut FileRegistry,
166    interner: &mut StringInterner,
167    metadata_store: &mut NodeMetadataStore,
168    provenance: &[ClasspathProvenance],
169) -> ClasspathResult<EmissionResult> {
170    let mut helper = InternHelper::new(interner);
171    let mut fqn_to_node: HashMap<String, NodeId> = HashMap::with_capacity(index.classes.len());
172    let mut fqn_to_nodes: HashMap<String, Vec<ClasspathNodeRef>> =
173        HashMap::with_capacity(index.classes.len());
174    let mut file_id_map: HashMap<String, FileId> = HashMap::new();
175
176    // Build a lookup from JAR path to provenance for O(1) access.
177    let prov_map: HashMap<&Path, &ClasspathProvenance> = provenance
178        .iter()
179        .map(|p| (p.jar_path.as_path(), p))
180        .collect();
181
182    for stub in &index.classes {
183        emit_class_stub(
184            stub,
185            staging,
186            file_registry,
187            &mut helper,
188            metadata_store,
189            &prov_map,
190            &mut fqn_to_node,
191            &mut fqn_to_nodes,
192            &mut file_id_map,
193        )?;
194    }
195
196    Ok(EmissionResult {
197        fqn_to_node,
198        fqn_to_nodes,
199        file_id_map,
200    })
201}
202
203/// Duplicate-aware emitted node reference.
204#[derive(Debug, Clone)]
205pub struct ClasspathNodeRef {
206    /// Graph node id for the emitted symbol.
207    pub node_id: NodeId,
208    /// Fully qualified name for duplicate-aware disambiguation.
209    pub fqn: String,
210    /// JAR path the symbol originated from.
211    pub jar_path: PathBuf,
212    /// Synthetic file id registered for this emitted symbol.
213    pub file_id: FileId,
214}
215
216/// Result of classpath node emission.
217#[derive(Debug)]
218pub struct EmissionResult {
219    /// Mapping from fully qualified class name to its graph `NodeId`.
220    pub fqn_to_node: HashMap<String, NodeId>,
221    /// Duplicate-aware mapping from fully qualified name to emitted node refs.
222    pub fqn_to_nodes: HashMap<String, Vec<ClasspathNodeRef>>,
223    /// Mapping from fully qualified class name to its synthetic `FileId`.
224    pub file_id_map: HashMap<String, FileId>,
225}
226
227/// Emit classpath nodes and structural edges directly into a [`CodeGraph`].
228///
229/// This mutates the graph in place so existing graph state such as confidence
230/// and epoch metadata is preserved. It commits staged nodes into the node
231/// arena, remaps staged edges, inserts them into the bidirectional edge store,
232/// remaps metadata keys, and returns the final classpath `FQN -> NodeId` map.
233///
234/// # Errors
235///
236/// Returns [`ClasspathError::EmissionError`] if staging commit, string
237/// interning, or file registration fails.
238pub fn emit_into_code_graph(
239    index: &ClasspathIndex,
240    graph: &mut CodeGraph,
241    provenance: &[ClasspathProvenance],
242) -> ClasspathResult<EmissionResult> {
243    // Work against local clones first so a fallible emission path cannot leave
244    // the caller's graph partially emptied on early return.
245    let mut nodes = graph.nodes().clone();
246    let edges = graph.edges().clone();
247    let mut strings = graph.strings().clone();
248    let mut files = graph.files().clone();
249    let mut metadata = graph.macro_metadata().clone();
250
251    let mut staging = StagingGraph::new();
252    let emission_result = emit_classpath_nodes(
253        index,
254        &mut staging,
255        &mut files,
256        &mut strings,
257        &mut metadata,
258        provenance,
259    )?;
260
261    create_classpath_edges(
262        index,
263        &emission_result.fqn_to_nodes,
264        provenance,
265        &mut staging,
266    );
267
268    let id_mapping = staging
269        .commit_nodes(&mut nodes)
270        .map_err(|e| ClasspathError::EmissionError(format!("node commit failed: {e}")))?;
271
272    for edge in staging.get_remapped_edges(&id_mapping) {
273        let _delta = edges.add_edge(edge.source, edge.target, edge.kind, edge.file);
274    }
275
276    let mut remapped_fqn_to_node = HashMap::with_capacity(emission_result.fqn_to_node.len());
277    for (fqn, node_id) in emission_result.fqn_to_node {
278        let remapped_id = id_mapping.get(&node_id).copied().unwrap_or(node_id);
279        remapped_fqn_to_node.insert(fqn, remapped_id);
280    }
281
282    let mut remapped_fqn_to_nodes = HashMap::with_capacity(emission_result.fqn_to_nodes.len());
283    for (fqn, refs) in emission_result.fqn_to_nodes {
284        let remapped_refs = refs
285            .into_iter()
286            .map(|node_ref| ClasspathNodeRef {
287                node_id: id_mapping
288                    .get(&node_ref.node_id)
289                    .copied()
290                    .unwrap_or(node_ref.node_id),
291                fqn: node_ref.fqn,
292                jar_path: node_ref.jar_path,
293                file_id: node_ref.file_id,
294            })
295            .collect();
296        remapped_fqn_to_nodes.insert(fqn, remapped_refs);
297    }
298
299    if !id_mapping.is_empty() {
300        let remapped_entries: Vec<_> = metadata
301            .iter_entries()
302            .map(|((index, generation), entry)| {
303                let node_id = NodeId::new(index, generation);
304                let remapped_id = id_mapping.get(&node_id).copied().unwrap_or(node_id);
305                (remapped_id, entry.clone())
306            })
307            .collect();
308        // Classpath/JVM stub nodes never carry a shape descriptor (those are
309        // computed only for tree-sitter function/method bodies during language
310        // staging, never on the bytecode-stub path), so this map is empty in
311        // practice. Remap and carry it through anyway so the rebuild below can
312        // never silently strand a descriptor if that ever changes.
313        let remapped_shape_descriptors: Vec<_> = metadata
314            .shape_descriptors()
315            .iter()
316            .map(|(node_id, descriptor)| {
317                let remapped_id = id_mapping.get(node_id).copied().unwrap_or(*node_id);
318                (remapped_id, descriptor.clone())
319            })
320            .collect();
321        metadata = NodeMetadataStore::new();
322        for (node_id, entry) in remapped_entries {
323            metadata.insert_entry(node_id, entry);
324        }
325        for (node_id, descriptor) in remapped_shape_descriptors {
326            metadata.insert_shape_descriptor(node_id, descriptor);
327        }
328    }
329
330    let _old_nodes = std::mem::replace(graph.nodes_mut(), nodes);
331    let _old_edges = std::mem::replace(graph.edges_mut(), edges);
332    let _old_strings = std::mem::replace(graph.strings_mut(), strings);
333    let _old_files = std::mem::replace(graph.files_mut(), files);
334    let _old_metadata = std::mem::replace(graph.macro_metadata_mut(), metadata);
335
336    Ok(EmissionResult {
337        fqn_to_node: remapped_fqn_to_node,
338        fqn_to_nodes: remapped_fqn_to_nodes,
339        file_id_map: emission_result.file_id_map,
340    })
341}
342
343/// Emit a single class stub and all its members into the staging graph.
344#[allow(clippy::too_many_arguments)] // Emission state is threaded explicitly for staging performance
345#[allow(clippy::too_many_lines)]
346#[allow(clippy::similar_names)] // Domain variable naming is intentional
347fn emit_class_stub(
348    stub: &ClassStub,
349    staging: &mut StagingGraph,
350    file_registry: &mut FileRegistry,
351    helper: &mut InternHelper<'_>,
352    metadata_store: &mut NodeMetadataStore,
353    prov_map: &HashMap<&Path, &ClasspathProvenance>,
354    fqn_to_node: &mut HashMap<String, NodeId>,
355    fqn_to_nodes: &mut HashMap<String, Vec<ClasspathNodeRef>>,
356    file_id_map: &mut HashMap<String, FileId>,
357) -> ClasspathResult<()> {
358    // Determine JAR path from the stub's source_jar (set during scanning),
359    // falling back to the first provenance entry or a synthetic path.
360    let jar_path = if let Some(ref src_jar) = stub.source_jar {
361        PathBuf::from(src_jar)
362    } else if let Some((&path, _)) = prov_map.iter().next() {
363        path.to_path_buf()
364    } else {
365        PathBuf::from(format!("<classpath>/{}.class", stub.fqn.replace('.', "/")))
366    };
367    let file_id = register_synthetic_file(&jar_path, &stub.fqn, file_registry)?;
368    file_id_map.insert(stub.fqn.clone(), file_id);
369
370    // --- Class node ---
371    let node_kind = class_kind_to_node_kind(stub.kind);
372    let name_id = helper.intern(&stub.name)?;
373    let qname_id = helper.intern(&stub.fqn)?;
374    let vis_id = helper.intern(access_to_visibility(&stub.access))?;
375
376    let class_entry = NodeEntry::new(node_kind, name_id, file_id)
377        .with_qualified_name(qname_id)
378        .with_visibility(vis_id)
379        .with_static(stub.access.is_static())
380        .with_unsafe(false);
381
382    let class_node_id = staging.add_node(class_entry);
383    record_node_ref(
384        &stub.fqn,
385        class_node_id,
386        &jar_path,
387        file_id,
388        fqn_to_node,
389        fqn_to_nodes,
390    );
391
392    // --- Build ClasspathNodeMetadata (shared by class and all members) ---
393    let prov = find_provenance_for_jar(&jar_path, prov_map);
394    let cp_meta = ClasspathNodeMetadata {
395        coordinates: prov.and_then(|p| p.coordinates.clone()),
396        jar_path: jar_path.display().to_string(),
397        fqn: stub.fqn.clone(),
398        is_direct_dependency: prov.is_some_and(ClasspathProvenance::has_direct_scope),
399    };
400    metadata_store.insert_typed(class_node_id, TypedMetadata::Classpath(cp_meta.clone()));
401
402    // --- Methods ---
403    for method in &stub.methods {
404        let method_name_id = helper.intern(&method.name)?;
405        // Include the descriptor in the key to disambiguate overloaded methods.
406        // JVM methods are uniquely identified by (name, descriptor).
407        let method_fqn = format!("{}.{}{}", stub.fqn, method.name, method.descriptor);
408        #[allow(clippy::similar_names)] // Domain terminology: source/target node pairs
409        let method_qname_id = helper.intern(&method_fqn)?;
410        let method_vis_id = helper.intern(access_to_visibility(&method.access))?;
411
412        let method_entry = NodeEntry::new(NodeKind::Method, method_name_id, file_id)
413            .with_qualified_name(method_qname_id)
414            .with_visibility(method_vis_id)
415            .with_static(method.access.is_static());
416
417        let method_node_id = staging.add_node(method_entry);
418        record_node_ref(
419            &method_fqn,
420            method_node_id,
421            &jar_path,
422            file_id,
423            fqn_to_node,
424            fqn_to_nodes,
425        );
426        metadata_store.insert_typed(method_node_id, TypedMetadata::Classpath(cp_meta.clone()));
427
428        // Class → Method: Defines
429        staging.add_edge(class_node_id, method_node_id, EdgeKind::Defines, file_id);
430    }
431
432    // --- Fields ---
433    for field in &stub.fields {
434        let field_name_id = helper.intern(&field.name)?;
435        let field_fqn = format!("{}.{}", stub.fqn, field.name);
436        let field_qname_id = helper.intern(&field_fqn)?;
437        let field_vis_id = helper.intern(access_to_visibility(&field.access))?;
438
439        let field_entry = NodeEntry::new(NodeKind::Property, field_name_id, file_id)
440            .with_qualified_name(field_qname_id)
441            .with_visibility(field_vis_id)
442            .with_static(field.access.is_static());
443
444        let field_node_id = staging.add_node(field_entry);
445        record_node_ref(
446            &field_fqn,
447            field_node_id,
448            &jar_path,
449            file_id,
450            fqn_to_node,
451            fqn_to_nodes,
452        );
453        metadata_store.insert_typed(field_node_id, TypedMetadata::Classpath(cp_meta.clone()));
454
455        // Class → Field: Defines
456        staging.add_edge(class_node_id, field_node_id, EdgeKind::Defines, file_id);
457    }
458
459    // --- Enum constants ---
460    for constant_name in &stub.enum_constants {
461        let const_name_id = helper.intern(constant_name)?;
462        let const_fqn = format!("{}.{constant_name}", stub.fqn);
463        let const_qname_id = helper.intern(&const_fqn)?;
464
465        let const_entry = NodeEntry::new(NodeKind::EnumConstant, const_name_id, file_id)
466            .with_qualified_name(const_qname_id)
467            .with_visibility(helper.intern("public")?);
468
469        let const_node_id = staging.add_node(const_entry);
470        record_node_ref(
471            &const_fqn,
472            const_node_id,
473            &jar_path,
474            file_id,
475            fqn_to_node,
476            fqn_to_nodes,
477        );
478        metadata_store.insert_typed(const_node_id, TypedMetadata::Classpath(cp_meta.clone()));
479
480        // Class → EnumConstant: Defines
481        staging.add_edge(class_node_id, const_node_id, EdgeKind::Defines, file_id);
482    }
483
484    // --- Type parameters ---
485    if let Some(ref gen_sig) = stub.generic_signature {
486        for tp in &gen_sig.type_parameters {
487            let tp_name_id = helper.intern(&tp.name)?;
488            let tp_fqn = format!("{}.<{}>", stub.fqn, tp.name);
489            let tp_qname_id = helper.intern(&tp_fqn)?;
490
491            let tp_entry = NodeEntry::new(NodeKind::TypeParameter, tp_name_id, file_id)
492                .with_qualified_name(tp_qname_id);
493
494            let tp_node_id = staging.add_node(tp_entry);
495            record_node_ref(
496                &tp_fqn,
497                tp_node_id,
498                &jar_path,
499                file_id,
500                fqn_to_node,
501                fqn_to_nodes,
502            );
503            metadata_store.insert_typed(tp_node_id, TypedMetadata::Classpath(cp_meta.clone()));
504
505            // Class → TypeParameter: TypeArgument
506            staging.add_edge(class_node_id, tp_node_id, EdgeKind::TypeArgument, file_id);
507        }
508    }
509
510    // --- Lambda targets ---
511    for lambda in &stub.lambda_targets {
512        let lambda_label = format!("{}::{}", lambda.owner_fqn, lambda.method_name);
513        let lambda_name_id = helper.intern(&lambda_label)?;
514        let lambda_fqn = format!("{}.lambda${}", stub.fqn, lambda.method_name);
515        let lambda_qname_id = helper.intern(&lambda_fqn)?;
516
517        let lambda_entry = NodeEntry::new(NodeKind::LambdaTarget, lambda_name_id, file_id)
518            .with_qualified_name(lambda_qname_id);
519
520        let lambda_node_id = staging.add_node(lambda_entry);
521        record_node_ref(
522            &lambda_fqn,
523            lambda_node_id,
524            &jar_path,
525            file_id,
526            fqn_to_node,
527            fqn_to_nodes,
528        );
529        metadata_store.insert_typed(lambda_node_id, TypedMetadata::Classpath(cp_meta.clone()));
530
531        // Class → LambdaTarget: Contains
532        staging.add_edge(class_node_id, lambda_node_id, EdgeKind::Contains, file_id);
533    }
534
535    // --- Inner classes (Contains edge) ---
536    for inner in &stub.inner_classes {
537        // Only emit Contains edge if the inner class belongs to this outer class
538        // and has been separately emitted (will be linked post-emission).
539        if inner.outer_fqn.as_deref() == Some(&stub.fqn) {
540            // The inner class itself will be emitted as its own ClassStub.
541            // We record the relationship for edge creation in create_classpath_edges.
542            // Store the inner FQN in fqn_to_node so we can link later.
543            // Actual Contains edge is deferred to create_classpath_edges where
544            // both nodes are guaranteed to exist.
545        }
546    }
547
548    // --- Module info ---
549    if let Some(ref module) = stub.module {
550        let mod_name_id = helper.intern(&module.name)?;
551        let mod_fqn = format!("module:{}", module.name);
552        let mod_qname_id = helper.intern(&mod_fqn)?;
553
554        let mod_entry = NodeEntry::new(NodeKind::JavaModule, mod_name_id, file_id)
555            .with_qualified_name(mod_qname_id);
556
557        let mod_node_id = staging.add_node(mod_entry);
558        record_node_ref(
559            &mod_fqn,
560            mod_node_id,
561            &jar_path,
562            file_id,
563            fqn_to_node,
564            fqn_to_nodes,
565        );
566        metadata_store.insert_typed(mod_node_id, TypedMetadata::Classpath(cp_meta.clone()));
567
568        // Class → Module: Contains
569        staging.add_edge(class_node_id, mod_node_id, EdgeKind::Contains, file_id);
570    }
571
572    Ok(())
573}
574
575fn record_node_ref(
576    fqn: &str,
577    node_id: NodeId,
578    jar_path: &Path,
579    file_id: FileId,
580    fqn_to_node: &mut HashMap<String, NodeId>,
581    fqn_to_nodes: &mut HashMap<String, Vec<ClasspathNodeRef>>,
582) {
583    fqn_to_node.entry(fqn.to_owned()).or_insert(node_id);
584    fqn_to_nodes
585        .entry(fqn.to_owned())
586        .or_default()
587        .push(ClasspathNodeRef {
588            node_id,
589            fqn: fqn.to_owned(),
590            jar_path: jar_path.to_path_buf(),
591            file_id,
592        });
593}
594
595// `find_jar_path_for_stub` has been replaced by `stub.source_jar` inline
596// in `emit_class_stub`. The JAR path is now tracked per-stub during scanning,
597// eliminating the incorrect "first provenance entry" heuristic.
598
599/// Look up provenance for a JAR path.
600fn find_provenance_for_jar<'a>(
601    jar_path: &Path,
602    prov_map: &HashMap<&Path, &'a ClasspathProvenance>,
603) -> Option<&'a ClasspathProvenance> {
604    prov_map.get(jar_path).copied()
605}
606
607// ---------------------------------------------------------------------------
608// Cross-reference edge creation (U15b)
609// ---------------------------------------------------------------------------
610
611/// Create inheritance, generic, annotation, module, and inner-class edges
612/// for classpath nodes.
613///
614/// Only creates edges where both source and target nodes exist in the graph.
615/// Missing targets are silently skipped (they may be from JARs not on the
616/// classpath).
617#[allow(clippy::too_many_lines)]
618#[allow(clippy::implicit_hasher)] // Standard HashMap intentional
619pub fn create_classpath_edges(
620    #[allow(clippy::implicit_hasher)] // Standard HashMap is intentional
621    index: &ClasspathIndex,
622    fqn_to_nodes: &HashMap<String, Vec<ClasspathNodeRef>>,
623    provenance: &[ClasspathProvenance],
624    staging: &mut StagingGraph,
625) {
626    let prov_map: HashMap<&Path, &ClasspathProvenance> = provenance
627        .iter()
628        .map(|p| (p.jar_path.as_path(), p))
629        .collect();
630
631    for stub in &index.classes {
632        let source_jar = stub.source_jar.as_deref().map(Path::new);
633        let Some(class_node) = select_node_ref(&stub.fqn, source_jar, fqn_to_nodes, &prov_map)
634        else {
635            continue;
636        };
637        let class_node_id = class_node.node_id;
638        let file_id = class_node.file_id;
639
640        // --- Inheritance (Inherits) ---
641        if let Some(ref superclass_fqn) = stub.superclass
642            && superclass_fqn != "java.lang.Object"
643        {
644            if let Some(super_node) =
645                select_node_ref(superclass_fqn, source_jar, fqn_to_nodes, &prov_map)
646            {
647                let super_node_id = super_node.node_id;
648                staging.add_edge(class_node_id, super_node_id, EdgeKind::Inherits, file_id);
649            } else {
650                log::debug!(
651                    "classpath: skipping Inherits edge for {} → {} (target not in graph)",
652                    stub.fqn,
653                    superclass_fqn
654                );
655            }
656        }
657
658        // --- Interface implementation (Implements) ---
659        for iface_fqn in &stub.interfaces {
660            if let Some(iface_node) =
661                select_node_ref(iface_fqn, source_jar, fqn_to_nodes, &prov_map)
662            {
663                let iface_node_id = iface_node.node_id;
664                staging.add_edge(class_node_id, iface_node_id, EdgeKind::Implements, file_id);
665            } else {
666                log::debug!(
667                    "classpath: skipping Implements edge for {} → {} (target not in graph)",
668                    stub.fqn,
669                    iface_fqn
670                );
671            }
672        }
673
674        // --- Generic bounds (GenericBound) ---
675        if let Some(ref gen_sig) = stub.generic_signature {
676            for tp in &gen_sig.type_parameters {
677                let tp_fqn = format!("{}.<{}>", stub.fqn, tp.name);
678                let Some(tp_node) = select_node_ref(&tp_fqn, source_jar, fqn_to_nodes, &prov_map)
679                else {
680                    continue;
681                };
682                let tp_node_id = tp_node.node_id;
683
684                // Class bound
685                if let Some(ref bound) = tp.class_bound
686                    && let Some(bound_fqn) = extract_class_fqn_from_type_sig(bound)
687                    && let Some(bound_node) =
688                        select_node_ref(bound_fqn, source_jar, fqn_to_nodes, &prov_map)
689                {
690                    let bound_node_id = bound_node.node_id;
691                    staging.add_edge(tp_node_id, bound_node_id, EdgeKind::GenericBound, file_id);
692                }
693
694                // Interface bounds
695                for ibound in &tp.interface_bounds {
696                    if let Some(bound_fqn) = extract_class_fqn_from_type_sig(ibound)
697                        && let Some(bound_node) =
698                            select_node_ref(bound_fqn, source_jar, fqn_to_nodes, &prov_map)
699                    {
700                        let bound_node_id = bound_node.node_id;
701                        staging.add_edge(
702                            tp_node_id,
703                            bound_node_id,
704                            EdgeKind::GenericBound,
705                            file_id,
706                        );
707                    }
708                }
709            }
710        }
711
712        // --- Annotations (AnnotatedWith) ---
713        for ann in &stub.annotations {
714            if let Some(ann_type_node) =
715                select_node_ref(&ann.type_fqn, source_jar, fqn_to_nodes, &prov_map)
716            {
717                let ann_type_node_id = ann_type_node.node_id;
718                staging.add_edge(
719                    class_node_id,
720                    ann_type_node_id,
721                    EdgeKind::AnnotatedWith,
722                    file_id,
723                );
724            }
725        }
726
727        // Method-level annotations
728        for method in &stub.methods {
729            let method_fqn = format!("{}.{}{}", stub.fqn, method.name, method.descriptor);
730            if let Some(method_node) =
731                select_node_ref(&method_fqn, source_jar, fqn_to_nodes, &prov_map)
732            {
733                let method_node_id = method_node.node_id;
734                for ann in &method.annotations {
735                    if let Some(ann_type_node) =
736                        select_node_ref(&ann.type_fqn, source_jar, fqn_to_nodes, &prov_map)
737                    {
738                        let ann_type_node_id = ann_type_node.node_id;
739                        staging.add_edge(
740                            method_node_id,
741                            ann_type_node_id,
742                            EdgeKind::AnnotatedWith,
743                            file_id,
744                        );
745                    }
746                }
747            }
748        }
749
750        // Field-level annotations
751        for field in &stub.fields {
752            let field_fqn = format!("{}.{}", stub.fqn, field.name);
753            if let Some(field_node) =
754                select_node_ref(&field_fqn, source_jar, fqn_to_nodes, &prov_map)
755            {
756                let field_node_id = field_node.node_id;
757                for ann in &field.annotations {
758                    if let Some(ann_type_node) =
759                        select_node_ref(&ann.type_fqn, source_jar, fqn_to_nodes, &prov_map)
760                    {
761                        let ann_type_node_id = ann_type_node.node_id;
762                        staging.add_edge(
763                            field_node_id,
764                            ann_type_node_id,
765                            EdgeKind::AnnotatedWith,
766                            file_id,
767                        );
768                    }
769                }
770            }
771        }
772
773        // --- Inner classes (Contains) ---
774        for inner in &stub.inner_classes {
775            if inner.outer_fqn.as_deref() == Some(&stub.fqn)
776                && let Some(inner_node) =
777                    select_node_ref(&inner.inner_fqn, source_jar, fqn_to_nodes, &prov_map)
778            {
779                let inner_node_id = inner_node.node_id;
780                staging.add_edge(class_node_id, inner_node_id, EdgeKind::Contains, file_id);
781            }
782        }
783
784        // --- Module edges ---
785        if let Some(ref module) = stub.module {
786            let mod_fqn = format!("module:{}", module.name);
787            let Some(mod_node) = select_node_ref(&mod_fqn, source_jar, fqn_to_nodes, &prov_map)
788            else {
789                continue;
790            };
791            let mod_node_id = mod_node.node_id;
792
793            // ModuleRequires
794            for req in &module.requires {
795                let req_mod_fqn = format!("module:{}", req.module_name);
796                if let Some(req_node) =
797                    select_node_ref(&req_mod_fqn, source_jar, fqn_to_nodes, &prov_map)
798                {
799                    let req_node_id = req_node.node_id;
800                    staging.add_edge(mod_node_id, req_node_id, EdgeKind::ModuleRequires, file_id);
801                }
802            }
803
804            // ModuleExports - edge from module to classes in exported package
805            for exp in &module.exports {
806                // Look up classes in the exported package
807                let pkg_classes = index.lookup_package(&exp.package);
808                for pkg_class in pkg_classes {
809                    for pkg_class_node in
810                        select_node_refs(&pkg_class.fqn, source_jar, fqn_to_nodes, &prov_map)
811                    {
812                        let pkg_class_node_id = pkg_class_node.node_id;
813                        staging.add_edge(
814                            mod_node_id,
815                            pkg_class_node_id,
816                            EdgeKind::ModuleExports,
817                            file_id,
818                        );
819                    }
820                }
821            }
822
823            // ModuleOpens - edge from module to classes in opened package
824            for opens in &module.opens {
825                let pkg_classes = index.lookup_package(&opens.package);
826                for pkg_class in pkg_classes {
827                    for pkg_class_node in
828                        select_node_refs(&pkg_class.fqn, source_jar, fqn_to_nodes, &prov_map)
829                    {
830                        let pkg_class_node_id = pkg_class_node.node_id;
831                        staging.add_edge(
832                            mod_node_id,
833                            pkg_class_node_id,
834                            EdgeKind::ModuleOpens,
835                            file_id,
836                        );
837                    }
838                }
839            }
840
841            // ModuleProvides - edge from module to service implementation classes
842            for provides in &module.provides {
843                for impl_fqn in &provides.implementations {
844                    if let Some(impl_node) =
845                        select_node_ref(impl_fqn, source_jar, fqn_to_nodes, &prov_map)
846                    {
847                        let impl_node_id = impl_node.node_id;
848                        staging.add_edge(
849                            mod_node_id,
850                            impl_node_id,
851                            EdgeKind::ModuleProvides,
852                            file_id,
853                        );
854                    }
855                }
856            }
857        }
858    }
859}
860
861fn select_node_ref<'a>(
862    fqn: &str,
863    source_jar: Option<&Path>,
864    fqn_to_nodes: &'a HashMap<String, Vec<ClasspathNodeRef>>,
865    prov_map: &HashMap<&Path, &ClasspathProvenance>,
866) -> Option<&'a ClasspathNodeRef> {
867    let candidates = select_node_refs(fqn, source_jar, fqn_to_nodes, prov_map);
868    candidates.first().copied()
869}
870
871fn select_node_refs<'a>(
872    fqn: &str,
873    source_jar: Option<&Path>,
874    fqn_to_nodes: &'a HashMap<String, Vec<ClasspathNodeRef>>,
875    prov_map: &HashMap<&Path, &ClasspathProvenance>,
876) -> Vec<&'a ClasspathNodeRef> {
877    let Some(candidates) = fqn_to_nodes.get(fqn) else {
878        return Vec::new();
879    };
880
881    let Some(source_jar) = source_jar else {
882        return candidates.iter().collect();
883    };
884
885    if let Some(exact_match) = candidates
886        .iter()
887        .find(|candidate| candidate.jar_path.as_path() == source_jar)
888    {
889        return vec![exact_match];
890    }
891
892    let scoped: Vec<_> = candidates
893        .iter()
894        .filter(|candidate| jars_share_scope(source_jar, candidate.jar_path.as_path(), prov_map))
895        .collect();
896    if scoped.is_empty() {
897        candidates.iter().collect()
898    } else {
899        scoped
900    }
901}
902
903fn jars_share_scope(
904    source_jar: &Path,
905    target_jar: &Path,
906    prov_map: &HashMap<&Path, &ClasspathProvenance>,
907) -> bool {
908    if source_jar == target_jar {
909        return true;
910    }
911
912    let Some(source) = prov_map.get(source_jar) else {
913        debug!(
914            "classpath: provenance missing for source jar {}; allowing scope fallback",
915            source_jar.display()
916        );
917        return true;
918    };
919    let Some(target) = prov_map.get(target_jar) else {
920        debug!(
921            "classpath: provenance missing for target jar {}; allowing scope fallback",
922            target_jar.display()
923        );
924        return true;
925    };
926
927    source.scopes.iter().any(|source_scope| {
928        target
929            .scopes
930            .iter()
931            .any(|target_scope| source_scope.module_root == target_scope.module_root)
932    })
933}
934
935/// Extract the FQN from a `TypeSignature::Class` variant.
936fn extract_class_fqn_from_type_sig(sig: &crate::stub::model::TypeSignature) -> Option<&str> {
937    match sig {
938        crate::stub::model::TypeSignature::Class { fqn, .. } => Some(fqn.as_str()),
939        _ => None,
940    }
941}
942
943// ---------------------------------------------------------------------------
944// Tests
945// ---------------------------------------------------------------------------
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950    use crate::stub::model::{
951        AccessFlags, AnnotationStub, ClassKind, FieldStub, GenericClassSignature, InnerClassEntry,
952        LambdaTargetStub, MethodStub, ModuleExports, ModuleProvides, ModuleRequires, ModuleStub,
953        ReferenceKind, TypeParameterStub, TypeSignature,
954    };
955    use sqry_core::graph::unified::BidirectionalEdgeStore;
956    use sqry_core::graph::unified::storage::AuxiliaryIndices;
957    use sqry_core::graph::unified::storage::NodeArena;
958
959    // -----------------------------------------------------------------------
960    // Test helpers
961    // -----------------------------------------------------------------------
962
963    fn make_interner() -> StringInterner {
964        StringInterner::new()
965    }
966
967    fn make_staging() -> StagingGraph {
968        StagingGraph::default()
969    }
970
971    fn make_provenance(jar: &str, direct: bool) -> ClasspathProvenance {
972        ClasspathProvenance {
973            jar_path: PathBuf::from(jar),
974            coordinates: Some(format!(
975                "group:artifact:{}",
976                if direct { "1.0" } else { "2.0" }
977            )),
978            is_direct: direct,
979            scopes: vec![crate::graph::provenance::ClasspathScope {
980                module_name: "test".to_owned(),
981                module_root: PathBuf::from("/repo/test"),
982                is_direct: direct,
983            }],
984        }
985    }
986
987    fn make_stub(fqn: &str) -> ClassStub {
988        ClassStub {
989            fqn: fqn.to_owned(),
990            name: fqn.rsplit('.').next().unwrap_or(fqn).to_owned(),
991            kind: ClassKind::Class,
992            access: AccessFlags::new(AccessFlags::ACC_PUBLIC),
993            superclass: Some("java.lang.Object".to_owned()),
994            interfaces: vec![],
995            methods: vec![],
996            fields: vec![],
997            annotations: vec![],
998            generic_signature: None,
999            inner_classes: vec![],
1000            lambda_targets: vec![],
1001            module: None,
1002            record_components: vec![],
1003            enum_constants: vec![],
1004            source_file: None,
1005            source_jar: None,
1006            kotlin_metadata: None,
1007            scala_signature: None,
1008        }
1009    }
1010
1011    fn make_method(name: &str) -> MethodStub {
1012        MethodStub {
1013            name: name.to_owned(),
1014            access: AccessFlags::new(AccessFlags::ACC_PUBLIC),
1015            descriptor: "()V".to_owned(),
1016            generic_signature: None,
1017            annotations: vec![],
1018            parameter_annotations: vec![],
1019            parameter_names: vec![],
1020            return_type: TypeSignature::Base(crate::stub::model::BaseType::Void),
1021            parameter_types: vec![],
1022        }
1023    }
1024
1025    fn make_field(name: &str) -> FieldStub {
1026        FieldStub {
1027            name: name.to_owned(),
1028            access: AccessFlags::new(AccessFlags::ACC_PRIVATE),
1029            descriptor: "I".to_owned(),
1030            generic_signature: None,
1031            annotations: vec![],
1032            constant_value: None,
1033        }
1034    }
1035
1036    /// Run emission and return the result plus the staging graph for inspection.
1037    fn run_emission(
1038        stubs: Vec<ClassStub>,
1039        provenance: &[ClasspathProvenance],
1040    ) -> (
1041        EmissionResult,
1042        StagingGraph,
1043        FileRegistry,
1044        StringInterner,
1045        NodeMetadataStore,
1046    ) {
1047        let default_jar = provenance
1048            .first()
1049            .map(|entry| entry.jar_path.display().to_string());
1050        let normalized_stubs = stubs
1051            .into_iter()
1052            .map(|mut stub| {
1053                if stub.source_jar.is_none() {
1054                    stub.source_jar = default_jar.clone();
1055                }
1056                stub
1057            })
1058            .collect();
1059        let index = ClasspathIndex::build(normalized_stubs);
1060        let mut staging = make_staging();
1061        let mut file_registry = FileRegistry::new();
1062        let mut interner = make_interner();
1063        let mut metadata_store = NodeMetadataStore::new();
1064
1065        let result = emit_classpath_nodes(
1066            &index,
1067            &mut staging,
1068            &mut file_registry,
1069            &mut interner,
1070            &mut metadata_store,
1071            provenance,
1072        )
1073        .expect("emission should succeed");
1074
1075        (result, staging, file_registry, interner, metadata_store)
1076    }
1077
1078    // -----------------------------------------------------------------------
1079    // Test 1: Simple class emits correct nodes
1080    // -----------------------------------------------------------------------
1081
1082    #[test]
1083    fn test_simple_class_emits_nodes() {
1084        let mut stub = make_stub("com.example.Foo");
1085        stub.methods = vec![make_method("bar"), make_method("baz")];
1086        stub.fields = vec![make_field("count")];
1087
1088        let prov = vec![make_provenance("/jars/example.jar", true)];
1089        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
1090
1091        // Class node
1092        assert!(
1093            result.fqn_to_node.contains_key("com.example.Foo"),
1094            "class node should be emitted"
1095        );
1096
1097        // Method nodes (key includes descriptor for overload disambiguation)
1098        assert!(
1099            result.fqn_to_node.contains_key("com.example.Foo.bar()V"),
1100            "method 'bar' should be emitted"
1101        );
1102        assert!(
1103            result.fqn_to_node.contains_key("com.example.Foo.baz()V"),
1104            "method 'baz' should be emitted"
1105        );
1106
1107        // Field node
1108        assert!(
1109            result.fqn_to_node.contains_key("com.example.Foo.count"),
1110            "field 'count' should be emitted"
1111        );
1112
1113        // Total: 1 class + 2 methods + 1 field = 4
1114        assert_eq!(result.fqn_to_node.len(), 4);
1115    }
1116
1117    // -----------------------------------------------------------------------
1118    // Test 2: Inheritance edge
1119    // -----------------------------------------------------------------------
1120
1121    #[test]
1122    fn test_inheritance_edge_created() {
1123        let mut list = make_stub("java.util.AbstractList");
1124        list.superclass = Some("java.util.AbstractCollection".to_owned());
1125
1126        let collection = make_stub("java.util.AbstractCollection");
1127
1128        let prov = vec![make_provenance("/jars/rt.jar", true)];
1129        let stubs = vec![list, collection];
1130        let index = ClasspathIndex::build(stubs.clone());
1131        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1132
1133        create_classpath_edges(&index, &result.fqn_to_nodes, &prov, &mut staging);
1134
1135        // Verify the AbstractList node exists
1136        assert!(result.fqn_to_node.contains_key("java.util.AbstractList"));
1137        assert!(
1138            result
1139                .fqn_to_node
1140                .contains_key("java.util.AbstractCollection")
1141        );
1142
1143        // Edge verification happens through staging stats
1144        let stats = staging.stats();
1145        // Structural edges (Defines for methods/fields) + Inherits edge
1146        assert!(
1147            stats.edges_staged > 0,
1148            "should have at least one edge staged"
1149        );
1150    }
1151
1152    // -----------------------------------------------------------------------
1153    // Test 3: Interface implementation edge
1154    // -----------------------------------------------------------------------
1155
1156    #[test]
1157    fn test_implements_edge_created() {
1158        let mut array_list = make_stub("java.util.ArrayList");
1159        array_list.interfaces = vec![
1160            "java.util.List".to_owned(),
1161            "java.io.Serializable".to_owned(),
1162        ];
1163
1164        let list_iface = {
1165            let mut s = make_stub("java.util.List");
1166            s.kind = ClassKind::Interface;
1167            s
1168        };
1169
1170        let serializable = {
1171            let mut s = make_stub("java.io.Serializable");
1172            s.kind = ClassKind::Interface;
1173            s
1174        };
1175
1176        let prov = vec![make_provenance("/jars/rt.jar", true)];
1177        let stubs = vec![array_list, list_iface, serializable];
1178        let index = ClasspathIndex::build(stubs.clone());
1179        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1180
1181        create_classpath_edges(&index, &result.fqn_to_nodes, &prov, &mut staging);
1182
1183        // All three should exist
1184        assert!(result.fqn_to_node.contains_key("java.util.ArrayList"));
1185        assert!(result.fqn_to_node.contains_key("java.util.List"));
1186        assert!(result.fqn_to_node.contains_key("java.io.Serializable"));
1187    }
1188
1189    #[test]
1190    fn test_emit_into_code_graph_commits_edges_and_metadata() {
1191        let mut child = make_stub("java.util.AbstractList");
1192        child.superclass = Some("java.util.AbstractCollection".to_owned());
1193        child.source_jar = Some("/jars/rt.jar".to_owned());
1194
1195        let mut parent = make_stub("java.util.AbstractCollection");
1196        parent.source_jar = Some("/jars/rt.jar".to_owned());
1197
1198        let index = ClasspathIndex::build(vec![child, parent]);
1199        let provenance = vec![make_provenance("/jars/rt.jar", true)];
1200        let mut graph = CodeGraph::new();
1201
1202        let result = emit_into_code_graph(&index, &mut graph, &provenance)
1203            .expect("in-place classpath emission should succeed");
1204
1205        let child_id = *result
1206            .fqn_to_node
1207            .get("java.util.AbstractList")
1208            .expect("child class node should be returned");
1209        let parent_id = *result
1210            .fqn_to_node
1211            .get("java.util.AbstractCollection")
1212            .expect("parent class node should be returned");
1213
1214        assert!(
1215            graph.nodes().get(child_id).is_some(),
1216            "child node should exist"
1217        );
1218        assert!(
1219            graph.edge_count() > 0,
1220            "structural classpath edges should be committed"
1221        );
1222        assert!(
1223            graph.edges().edges_from(child_id).iter().any(|edge| {
1224                matches!(edge.kind, EdgeKind::Inherits) && edge.target == parent_id
1225            }),
1226            "child class should have an inherits edge to its parent"
1227        );
1228        assert!(
1229            matches!(
1230                graph.macro_metadata().get_typed(child_id),
1231                Some(TypedMetadata::Classpath(_))
1232            ),
1233            "classpath metadata should remain attached after node-id remap"
1234        );
1235    }
1236
1237    #[test]
1238    fn test_emit_into_code_graph_preserves_graph_on_failure() {
1239        let mut strings = StringInterner::with_max_ids(2);
1240        let existing_name = strings.intern("existing").unwrap();
1241        let existing_qname = strings.intern("existing::node").unwrap();
1242
1243        let mut files = FileRegistry::new();
1244        let file_id = files.register(Path::new("/existing.rs")).unwrap();
1245
1246        let mut nodes = NodeArena::new();
1247        let existing_node = nodes
1248            .alloc(
1249                NodeEntry::new(NodeKind::Function, existing_name, file_id)
1250                    .with_qualified_name(existing_qname),
1251            )
1252            .unwrap();
1253
1254        let mut graph = CodeGraph::from_components(
1255            nodes,
1256            BidirectionalEdgeStore::new(),
1257            strings,
1258            files,
1259            AuxiliaryIndices::new(),
1260            NodeMetadataStore::new(),
1261        );
1262        graph.macro_metadata_mut().insert_typed(
1263            existing_node,
1264            TypedMetadata::Classpath(ClasspathNodeMetadata {
1265                coordinates: Some("group:existing:1.0".to_owned()),
1266                jar_path: "/existing.jar".to_owned(),
1267                fqn: "existing::node".to_owned(),
1268                is_direct_dependency: true,
1269            }),
1270        );
1271
1272        let snapshot_before = graph.snapshot();
1273        let existing_path_before = snapshot_before
1274            .files()
1275            .resolve(file_id)
1276            .expect("existing file should resolve")
1277            .to_path_buf();
1278        let existing_meta_before = snapshot_before
1279            .macro_metadata()
1280            .get_typed(existing_node)
1281            .cloned();
1282
1283        let mut failing_stub = make_stub("com.example.WillFail");
1284        failing_stub.source_jar = Some("/jars/failing.jar".to_owned());
1285        let index = ClasspathIndex::build(vec![failing_stub]);
1286        let provenance = vec![make_provenance("/jars/failing.jar", true)];
1287
1288        let error = emit_into_code_graph(&index, &mut graph, &provenance)
1289            .expect_err("emission should fail when the cloned interner is full");
1290        assert!(
1291            error.to_string().contains("string intern failed"),
1292            "unexpected error: {error}"
1293        );
1294
1295        assert_eq!(graph.node_count(), snapshot_before.nodes().len());
1296        assert_eq!(graph.edge_count(), 0);
1297        assert_eq!(graph.strings().len(), snapshot_before.strings().len());
1298        assert_eq!(graph.files().len(), snapshot_before.files().len());
1299
1300        let surviving_node = graph
1301            .nodes()
1302            .get(existing_node)
1303            .expect("existing node must survive failed emission");
1304        assert_eq!(surviving_node.name, existing_name);
1305        assert_eq!(
1306            graph
1307                .files()
1308                .resolve(file_id)
1309                .map(|path| path.to_path_buf()),
1310            Some(existing_path_before)
1311        );
1312        assert_eq!(
1313            graph.macro_metadata().get_typed(existing_node),
1314            existing_meta_before.as_ref()
1315        );
1316    }
1317
1318    // -----------------------------------------------------------------------
1319    // Test 6: ClasspathNodeMetadata attached correctly
1320    // -----------------------------------------------------------------------
1321
1322    #[test]
1323    fn test_classpath_metadata_attached() {
1324        let stub = make_stub("com.google.common.collect.ImmutableList");
1325        let prov = vec![ClasspathProvenance {
1326            jar_path: PathBuf::from("/home/user/.m2/repository/guava-33.0.0.jar"),
1327            coordinates: Some("com.google.guava:guava:33.0.0".to_owned()),
1328            is_direct: true,
1329            scopes: vec![crate::graph::provenance::ClasspathScope {
1330                module_name: "test".to_owned(),
1331                module_root: PathBuf::from("/repo/test"),
1332                is_direct: true,
1333            }],
1334        }];
1335
1336        let (result, _staging, _registry, _interner, metadata_store) =
1337            run_emission(vec![stub], &prov);
1338
1339        let node_id = result
1340            .fqn_to_node
1341            .get("com.google.common.collect.ImmutableList")
1342            .expect("node should exist");
1343
1344        let metadata = metadata_store
1345            .get_typed(*node_id)
1346            .expect("metadata should be attached");
1347
1348        match metadata {
1349            TypedMetadata::Classpath(cp) => {
1350                assert_eq!(cp.fqn, "com.google.common.collect.ImmutableList");
1351                assert_eq!(
1352                    cp.coordinates.as_deref(),
1353                    Some("com.google.guava:guava:33.0.0")
1354                );
1355                assert!(cp.is_direct_dependency);
1356                assert!(cp.jar_path.contains("guava-33.0.0.jar"));
1357            }
1358            TypedMetadata::Macro(_) => panic!("expected Classpath metadata, got Macro"),
1359        }
1360    }
1361
1362    // -----------------------------------------------------------------------
1363    // Test 7: Zero spans on all nodes
1364    // -----------------------------------------------------------------------
1365
1366    #[test]
1367    fn test_zero_spans_on_classpath_nodes() {
1368        let mut stub = make_stub("com.example.ZeroSpan");
1369        stub.methods = vec![make_method("doWork")];
1370
1371        let prov = vec![make_provenance("/jars/test.jar", true)];
1372        let (result, staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
1373
1374        // All emitted nodes should have zero spans since they come from bytecode.
1375        // The NodeEntry::new constructor sets all span fields to 0 by default,
1376        // and we don't override them.
1377        assert!(
1378            !result.fqn_to_node.is_empty(),
1379            "should have emitted at least one node"
1380        );
1381
1382        // Verify through staging stats that nodes were emitted
1383        let stats = staging.stats();
1384        assert!(
1385            stats.nodes_staged >= 2,
1386            "should have at least 2 nodes (class + method)"
1387        );
1388    }
1389
1390    // -----------------------------------------------------------------------
1391    // Test 8: Annotation edges
1392    // -----------------------------------------------------------------------
1393
1394    #[test]
1395    fn test_annotation_edges() {
1396        let mut stub = make_stub("com.example.MyService");
1397        stub.annotations = vec![AnnotationStub {
1398            type_fqn: "org.springframework.stereotype.Service".to_owned(),
1399            elements: vec![],
1400            is_runtime_visible: true,
1401        }];
1402
1403        // Also emit the annotation type so the edge can be created
1404        let ann_type = {
1405            let mut s = make_stub("org.springframework.stereotype.Service");
1406            s.kind = ClassKind::Annotation;
1407            s
1408        };
1409
1410        let prov = vec![make_provenance("/jars/app.jar", true)];
1411        let stubs = vec![stub, ann_type];
1412        let index = ClasspathIndex::build(stubs.clone());
1413        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1414
1415        create_classpath_edges(&index, &result.fqn_to_nodes, &prov, &mut staging);
1416
1417        // Both nodes should exist
1418        assert!(result.fqn_to_node.contains_key("com.example.MyService"));
1419        assert!(
1420            result
1421                .fqn_to_node
1422                .contains_key("org.springframework.stereotype.Service")
1423        );
1424
1425        // Edge verification through staging stats
1426        let stats = staging.stats();
1427        assert!(
1428            stats.edges_staged > 0,
1429            "should have annotation edges staged"
1430        );
1431    }
1432
1433    // -----------------------------------------------------------------------
1434    // Test 9: Module edges
1435    // -----------------------------------------------------------------------
1436
1437    #[test]
1438    fn test_module_edges() {
1439        let provider_class = make_stub("com.example.spi.MyProvider");
1440        let exported_class = make_stub("com.example.api.MyApi");
1441
1442        let mut module_stub = make_stub("module-info");
1443        module_stub.kind = ClassKind::Module;
1444        module_stub.module = Some(ModuleStub {
1445            name: "com.example".to_owned(),
1446            access: AccessFlags::new(0),
1447            version: None,
1448            requires: vec![ModuleRequires {
1449                module_name: "java.base".to_owned(),
1450                access: AccessFlags::new(0),
1451                version: None,
1452            }],
1453            exports: vec![ModuleExports {
1454                package: "com.example.api".to_owned(),
1455                access: AccessFlags::new(0),
1456                to_modules: vec![],
1457            }],
1458            opens: vec![],
1459            provides: vec![ModuleProvides {
1460                service: "com.example.spi.SomeService".to_owned(),
1461                implementations: vec!["com.example.spi.MyProvider".to_owned()],
1462            }],
1463            uses: vec![],
1464        });
1465
1466        let prov = vec![make_provenance("/jars/example.jar", true)];
1467        let stubs = vec![module_stub, provider_class, exported_class];
1468        let index = ClasspathIndex::build(stubs.clone());
1469        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1470
1471        create_classpath_edges(&index, &result.fqn_to_nodes, &prov, &mut staging);
1472
1473        // Module node should exist
1474        assert!(
1475            result.fqn_to_node.contains_key("module:com.example"),
1476            "module node should be emitted"
1477        );
1478
1479        let stats = staging.stats();
1480        assert!(stats.edges_staged > 0, "should have module edges staged");
1481    }
1482
1483    // -----------------------------------------------------------------------
1484    // Test 10: Enum constants
1485    // -----------------------------------------------------------------------
1486
1487    #[test]
1488    fn test_enum_constants_emitted() {
1489        let mut stub = make_stub("java.time.DayOfWeek");
1490        stub.kind = ClassKind::Enum;
1491        stub.enum_constants = vec![
1492            "MONDAY".to_owned(),
1493            "TUESDAY".to_owned(),
1494            "WEDNESDAY".to_owned(),
1495        ];
1496
1497        let prov = vec![make_provenance("/jars/rt.jar", true)];
1498        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
1499
1500        assert!(
1501            result
1502                .fqn_to_node
1503                .contains_key("java.time.DayOfWeek.MONDAY")
1504        );
1505        assert!(
1506            result
1507                .fqn_to_node
1508                .contains_key("java.time.DayOfWeek.TUESDAY")
1509        );
1510        assert!(
1511            result
1512                .fqn_to_node
1513                .contains_key("java.time.DayOfWeek.WEDNESDAY")
1514        );
1515    }
1516
1517    // -----------------------------------------------------------------------
1518    // Test 11: Type parameters emitted
1519    // -----------------------------------------------------------------------
1520
1521    #[test]
1522    fn test_type_parameters_emitted() {
1523        let mut stub = make_stub("java.util.HashMap");
1524        stub.generic_signature = Some(GenericClassSignature {
1525            type_parameters: vec![
1526                TypeParameterStub {
1527                    name: "K".to_owned(),
1528                    class_bound: None,
1529                    interface_bounds: vec![],
1530                },
1531                TypeParameterStub {
1532                    name: "V".to_owned(),
1533                    class_bound: None,
1534                    interface_bounds: vec![],
1535                },
1536            ],
1537            superclass: TypeSignature::Class {
1538                fqn: "java.util.AbstractMap".to_owned(),
1539                type_arguments: vec![],
1540            },
1541            interfaces: vec![],
1542        });
1543
1544        let prov = vec![make_provenance("/jars/rt.jar", true)];
1545        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
1546
1547        assert!(result.fqn_to_node.contains_key("java.util.HashMap.<K>"));
1548        assert!(result.fqn_to_node.contains_key("java.util.HashMap.<V>"));
1549    }
1550
1551    // -----------------------------------------------------------------------
1552    // Test 12: Generic bound edges
1553    // -----------------------------------------------------------------------
1554
1555    #[test]
1556    fn test_generic_bound_edges() {
1557        let comparable = make_stub("java.lang.Comparable");
1558
1559        let mut stub = make_stub("com.example.Sorted");
1560        stub.generic_signature = Some(GenericClassSignature {
1561            type_parameters: vec![TypeParameterStub {
1562                name: "T".to_owned(),
1563                class_bound: Some(TypeSignature::Class {
1564                    fqn: "java.lang.Comparable".to_owned(),
1565                    type_arguments: vec![],
1566                }),
1567                interface_bounds: vec![],
1568            }],
1569            superclass: TypeSignature::Class {
1570                fqn: "java.lang.Object".to_owned(),
1571                type_arguments: vec![],
1572            },
1573            interfaces: vec![],
1574        });
1575
1576        let prov = vec![make_provenance("/jars/rt.jar", true)];
1577        let stubs = vec![stub, comparable];
1578        let index = ClasspathIndex::build(stubs.clone());
1579        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1580
1581        create_classpath_edges(&index, &result.fqn_to_nodes, &prov, &mut staging);
1582
1583        // Type parameter and bound target should exist
1584        assert!(result.fqn_to_node.contains_key("com.example.Sorted.<T>"));
1585        assert!(result.fqn_to_node.contains_key("java.lang.Comparable"));
1586    }
1587
1588    // -----------------------------------------------------------------------
1589    // Test 13: Lambda targets emitted
1590    // -----------------------------------------------------------------------
1591
1592    #[test]
1593    fn test_lambda_targets_emitted() {
1594        let mut stub = make_stub("com.example.Processor");
1595        stub.lambda_targets = vec![LambdaTargetStub {
1596            owner_fqn: "java.lang.String".to_owned(),
1597            method_name: "toUpperCase".to_owned(),
1598            method_descriptor: "()Ljava/lang/String;".to_owned(),
1599            reference_kind: ReferenceKind::InvokeVirtual,
1600        }];
1601
1602        let prov = vec![make_provenance("/jars/app.jar", true)];
1603        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
1604
1605        assert!(
1606            result
1607                .fqn_to_node
1608                .contains_key("com.example.Processor.lambda$toUpperCase")
1609        );
1610    }
1611
1612    // -----------------------------------------------------------------------
1613    // Test 14: Inner class Contains edge
1614    // -----------------------------------------------------------------------
1615
1616    #[test]
1617    fn test_inner_class_contains_edge() {
1618        let outer = {
1619            let mut s = make_stub("com.example.Outer");
1620            s.inner_classes = vec![InnerClassEntry {
1621                inner_fqn: "com.example.Outer.Inner".to_owned(),
1622                outer_fqn: Some("com.example.Outer".to_owned()),
1623                inner_name: Some("Inner".to_owned()),
1624                access: AccessFlags::new(AccessFlags::ACC_PUBLIC),
1625            }];
1626            s
1627        };
1628
1629        let inner = make_stub("com.example.Outer.Inner");
1630
1631        let prov = vec![make_provenance("/jars/app.jar", true)];
1632        let stubs = vec![outer, inner];
1633        let index = ClasspathIndex::build(stubs.clone());
1634        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1635
1636        create_classpath_edges(&index, &result.fqn_to_nodes, &prov, &mut staging);
1637
1638        assert!(result.fqn_to_node.contains_key("com.example.Outer"));
1639        assert!(result.fqn_to_node.contains_key("com.example.Outer.Inner"));
1640    }
1641
1642    // -----------------------------------------------------------------------
1643    // Test 15: Empty index produces empty result
1644    // -----------------------------------------------------------------------
1645
1646    #[test]
1647    fn test_empty_index_no_nodes() {
1648        let prov: Vec<ClasspathProvenance> = vec![];
1649        let (result, staging, _registry, _interner, _meta) = run_emission(vec![], &prov);
1650
1651        assert!(result.fqn_to_node.is_empty());
1652        assert_eq!(staging.stats().nodes_staged, 0);
1653    }
1654
1655    // -----------------------------------------------------------------------
1656    // Test 16: Synthetic file path convention
1657    // -----------------------------------------------------------------------
1658
1659    #[test]
1660    fn test_synthetic_file_path_convention() {
1661        let stub = make_stub("com.example.Foo");
1662        let prov = vec![make_provenance("/jars/example.jar", true)];
1663        let (result, _staging, file_registry, _interner, _meta) = run_emission(vec![stub], &prov);
1664
1665        let file_id = result
1666            .file_id_map
1667            .get("com.example.Foo")
1668            .expect("file ID should exist");
1669
1670        let path = file_registry
1671            .resolve(*file_id)
1672            .expect("file should be resolvable");
1673
1674        let path_str = path.to_string_lossy();
1675        assert!(
1676            path_str.contains("!/com/example/Foo.class"),
1677            "synthetic path should follow JAR convention, got: {path_str}"
1678        );
1679    }
1680
1681    // -----------------------------------------------------------------------
1682    // Test 17: Interface kind maps correctly
1683    // -----------------------------------------------------------------------
1684
1685    #[test]
1686    fn test_interface_kind_mapping() {
1687        let mut stub = make_stub("java.util.List");
1688        stub.kind = ClassKind::Interface;
1689
1690        let prov = vec![make_provenance("/jars/rt.jar", true)];
1691        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
1692
1693        assert!(result.fqn_to_node.contains_key("java.util.List"));
1694    }
1695
1696    // -----------------------------------------------------------------------
1697    // Test 18: Method-level annotation edge
1698    // -----------------------------------------------------------------------
1699
1700    #[test]
1701    fn test_method_level_annotation_edge() {
1702        let override_ann = {
1703            let mut s = make_stub("java.lang.Override");
1704            s.kind = ClassKind::Annotation;
1705            s
1706        };
1707
1708        let mut stub = make_stub("com.example.Foo");
1709        stub.methods = vec![MethodStub {
1710            name: "toString".to_owned(),
1711            access: AccessFlags::new(AccessFlags::ACC_PUBLIC),
1712            descriptor: "()Ljava/lang/String;".to_owned(),
1713            generic_signature: None,
1714            annotations: vec![AnnotationStub {
1715                type_fqn: "java.lang.Override".to_owned(),
1716                elements: vec![],
1717                is_runtime_visible: true,
1718            }],
1719            parameter_annotations: vec![],
1720            parameter_names: vec![],
1721            return_type: TypeSignature::Base(crate::stub::model::BaseType::Void),
1722            parameter_types: vec![],
1723        }];
1724
1725        let prov = vec![make_provenance("/jars/rt.jar", true)];
1726        let stubs = vec![stub, override_ann];
1727        let index = ClasspathIndex::build(stubs.clone());
1728        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1729
1730        create_classpath_edges(&index, &result.fqn_to_nodes, &prov, &mut staging);
1731
1732        assert!(
1733            result
1734                .fqn_to_node
1735                .contains_key("com.example.Foo.toString()Ljava/lang/String;")
1736        );
1737        assert!(result.fqn_to_node.contains_key("java.lang.Override"));
1738    }
1739
1740    // -----------------------------------------------------------------------
1741    // Test 19: No provenance still emits nodes
1742    // -----------------------------------------------------------------------
1743
1744    #[test]
1745    fn test_no_provenance_still_emits() {
1746        let stub = make_stub("com.example.NoProv");
1747        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &[]);
1748
1749        assert!(result.fqn_to_node.contains_key("com.example.NoProv"));
1750    }
1751
1752    // -----------------------------------------------------------------------
1753    // Test 20: Visibility mapping
1754    // -----------------------------------------------------------------------
1755
1756    #[test]
1757    fn test_visibility_mapping() {
1758        assert_eq!(
1759            access_to_visibility(&AccessFlags::new(AccessFlags::ACC_PUBLIC)),
1760            "public"
1761        );
1762        assert_eq!(
1763            access_to_visibility(&AccessFlags::new(AccessFlags::ACC_PROTECTED)),
1764            "protected"
1765        );
1766        assert_eq!(
1767            access_to_visibility(&AccessFlags::new(AccessFlags::ACC_PRIVATE)),
1768            "private"
1769        );
1770        assert_eq!(access_to_visibility(&AccessFlags::empty()), "package");
1771    }
1772}