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