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