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 sqry_core::graph::node::Language;
30use sqry_core::graph::unified::build::ExportMap;
31use sqry_core::graph::unified::build::StagingGraph;
32use sqry_core::graph::unified::edge::EdgeKind;
33use sqry_core::graph::unified::node::NodeKind;
34use sqry_core::graph::unified::storage::metadata::{
35    ClasspathNodeMetadata, NodeMetadata, NodeMetadataStore,
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.
91fn access_to_visibility(access: &crate::stub::model::AccessFlags) -> &'static str {
92    if access.is_public() {
93        "public"
94    } else if access.is_protected() {
95        "protected"
96    } else if access.is_private() {
97        "private"
98    } else {
99        "package"
100    }
101}
102
103/// Map `ClassKind` to the corresponding `NodeKind`.
104fn class_kind_to_node_kind(kind: ClassKind) -> NodeKind {
105    match kind {
106        ClassKind::Class | ClassKind::Record => NodeKind::Class,
107        ClassKind::Interface => NodeKind::Interface,
108        ClassKind::Enum => NodeKind::Enum,
109        ClassKind::Annotation => NodeKind::Annotation,
110        ClassKind::Module => NodeKind::JavaModule,
111    }
112}
113
114// ---------------------------------------------------------------------------
115// File ID management
116// ---------------------------------------------------------------------------
117
118/// Register a synthetic external file for a class within a JAR.
119///
120/// Path convention: `{jar_path}!/{fqn}.class` (similar to Java URL conventions
121/// for JAR entries like `jar:file:///path.jar!/com/example/Foo.class`).
122fn register_synthetic_file(
123    jar_path: &Path,
124    fqn: &str,
125    file_registry: &mut FileRegistry,
126) -> ClasspathResult<FileId> {
127    let class_path_str = fqn.replace('.', "/");
128    let synthetic_path = format!("{}!/{class_path_str}.class", jar_path.display());
129    let path = PathBuf::from(&synthetic_path);
130    file_registry
131        .register_external(&path, Some(Language::Java))
132        .map_err(|e| {
133            ClasspathError::EmissionError(format!(
134                "failed to register synthetic file for {fqn}: {e}"
135            ))
136        })
137}
138
139// ---------------------------------------------------------------------------
140// Node emission
141// ---------------------------------------------------------------------------
142
143/// Emit all classpath nodes and edges into a staging graph.
144///
145/// Returns the mapping of FQN to `NodeId` for ExportMap registration and
146/// 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 file_id_map: HashMap<String, FileId> = HashMap::new();
173
174    // Build a lookup from JAR path to provenance for O(1) access.
175    let prov_map: HashMap<&Path, &ClasspathProvenance> = provenance
176        .iter()
177        .map(|p| (p.jar_path.as_path(), p))
178        .collect();
179
180    for stub in &index.classes {
181        emit_class_stub(
182            stub,
183            staging,
184            file_registry,
185            &mut helper,
186            metadata_store,
187            &prov_map,
188            &mut fqn_to_node,
189            &mut file_id_map,
190        )?;
191    }
192
193    Ok(EmissionResult {
194        fqn_to_node,
195        file_id_map,
196    })
197}
198
199/// Result of classpath node emission.
200#[derive(Debug)]
201pub struct EmissionResult {
202    /// Mapping from fully qualified class name to its graph NodeId.
203    pub fqn_to_node: HashMap<String, NodeId>,
204    /// Mapping from fully qualified class name to its synthetic FileId.
205    pub file_id_map: HashMap<String, FileId>,
206}
207
208/// Emit a single class stub and all its members into the staging graph.
209#[allow(clippy::too_many_lines)]
210fn emit_class_stub(
211    stub: &ClassStub,
212    staging: &mut StagingGraph,
213    file_registry: &mut FileRegistry,
214    helper: &mut InternHelper<'_>,
215    metadata_store: &mut NodeMetadataStore,
216    prov_map: &HashMap<&Path, &ClasspathProvenance>,
217    fqn_to_node: &mut HashMap<String, NodeId>,
218    file_id_map: &mut HashMap<String, FileId>,
219) -> ClasspathResult<()> {
220    // Determine JAR path from the stub's source_jar (set during scanning),
221    // falling back to the first provenance entry or a synthetic path.
222    let jar_path = if let Some(ref src_jar) = stub.source_jar {
223        PathBuf::from(src_jar)
224    } else if let Some((&path, _)) = prov_map.iter().next() {
225        path.to_path_buf()
226    } else {
227        PathBuf::from(format!("<classpath>/{}.class", stub.fqn.replace('.', "/")))
228    };
229    let file_id = register_synthetic_file(&jar_path, &stub.fqn, file_registry)?;
230    file_id_map.insert(stub.fqn.clone(), file_id);
231
232    // --- Class node ---
233    let node_kind = class_kind_to_node_kind(stub.kind);
234    let name_id = helper.intern(&stub.name)?;
235    let qname_id = helper.intern(&stub.fqn)?;
236    let vis_id = helper.intern(access_to_visibility(&stub.access))?;
237
238    let class_entry = NodeEntry::new(node_kind, name_id, file_id)
239        .with_qualified_name(qname_id)
240        .with_visibility(vis_id)
241        .with_static(stub.access.is_static())
242        .with_unsafe(false);
243
244    let class_node_id = staging.add_node(class_entry);
245    fqn_to_node.insert(stub.fqn.clone(), class_node_id);
246
247    // --- Build ClasspathNodeMetadata (shared by class and all members) ---
248    let prov = find_provenance_for_jar(&jar_path, prov_map);
249    let cp_meta = ClasspathNodeMetadata {
250        coordinates: prov.and_then(|p| p.coordinates.clone()),
251        jar_path: jar_path.display().to_string(),
252        fqn: stub.fqn.clone(),
253        is_direct_dependency: prov.is_some_and(|p| p.is_direct),
254    };
255    metadata_store.insert_metadata(class_node_id, NodeMetadata::Classpath(cp_meta.clone()));
256
257    // --- Methods ---
258    for method in &stub.methods {
259        let method_name_id = helper.intern(&method.name)?;
260        // Include the descriptor in the key to disambiguate overloaded methods.
261        // JVM methods are uniquely identified by (name, descriptor).
262        let method_fqn = format!("{}.{}{}", stub.fqn, method.name, method.descriptor);
263        let method_qname_id = helper.intern(&method_fqn)?;
264        let method_vis_id = helper.intern(access_to_visibility(&method.access))?;
265
266        let method_entry = NodeEntry::new(NodeKind::Method, method_name_id, file_id)
267            .with_qualified_name(method_qname_id)
268            .with_visibility(method_vis_id)
269            .with_static(method.access.is_static());
270
271        let method_node_id = staging.add_node(method_entry);
272        fqn_to_node.insert(method_fqn, method_node_id);
273        metadata_store.insert_metadata(method_node_id, NodeMetadata::Classpath(cp_meta.clone()));
274
275        // Class → Method: Defines
276        staging.add_edge(class_node_id, method_node_id, EdgeKind::Defines, file_id);
277    }
278
279    // --- Fields ---
280    for field in &stub.fields {
281        let field_name_id = helper.intern(&field.name)?;
282        let field_fqn = format!("{}.{}", stub.fqn, field.name);
283        let field_qname_id = helper.intern(&field_fqn)?;
284        let field_vis_id = helper.intern(access_to_visibility(&field.access))?;
285
286        let field_entry = NodeEntry::new(NodeKind::Property, field_name_id, file_id)
287            .with_qualified_name(field_qname_id)
288            .with_visibility(field_vis_id)
289            .with_static(field.access.is_static());
290
291        let field_node_id = staging.add_node(field_entry);
292        fqn_to_node.insert(field_fqn, field_node_id);
293        metadata_store.insert_metadata(field_node_id, NodeMetadata::Classpath(cp_meta.clone()));
294
295        // Class → Field: Defines
296        staging.add_edge(class_node_id, field_node_id, EdgeKind::Defines, file_id);
297    }
298
299    // --- Enum constants ---
300    for constant_name in &stub.enum_constants {
301        let const_name_id = helper.intern(constant_name)?;
302        let const_fqn = format!("{}.{constant_name}", stub.fqn);
303        let const_qname_id = helper.intern(&const_fqn)?;
304
305        let const_entry = NodeEntry::new(NodeKind::EnumConstant, const_name_id, file_id)
306            .with_qualified_name(const_qname_id)
307            .with_visibility(helper.intern("public")?);
308
309        let const_node_id = staging.add_node(const_entry);
310        fqn_to_node.insert(const_fqn, const_node_id);
311        metadata_store.insert_metadata(const_node_id, NodeMetadata::Classpath(cp_meta.clone()));
312
313        // Class → EnumConstant: Defines
314        staging.add_edge(class_node_id, const_node_id, EdgeKind::Defines, file_id);
315    }
316
317    // --- Type parameters ---
318    if let Some(ref gen_sig) = stub.generic_signature {
319        for tp in &gen_sig.type_parameters {
320            let tp_name_id = helper.intern(&tp.name)?;
321            let tp_fqn = format!("{}.<{}>", stub.fqn, tp.name);
322            let tp_qname_id = helper.intern(&tp_fqn)?;
323
324            let tp_entry = NodeEntry::new(NodeKind::TypeParameter, tp_name_id, file_id)
325                .with_qualified_name(tp_qname_id);
326
327            let tp_node_id = staging.add_node(tp_entry);
328            fqn_to_node.insert(tp_fqn, tp_node_id);
329            metadata_store.insert_metadata(tp_node_id, NodeMetadata::Classpath(cp_meta.clone()));
330
331            // Class → TypeParameter: TypeArgument
332            staging.add_edge(class_node_id, tp_node_id, EdgeKind::TypeArgument, file_id);
333        }
334    }
335
336    // --- Lambda targets ---
337    for lambda in &stub.lambda_targets {
338        let lambda_label = format!("{}::{}", lambda.owner_fqn, lambda.method_name);
339        let lambda_name_id = helper.intern(&lambda_label)?;
340        let lambda_fqn = format!("{}.lambda${}", stub.fqn, lambda.method_name);
341        let lambda_qname_id = helper.intern(&lambda_fqn)?;
342
343        let lambda_entry = NodeEntry::new(NodeKind::LambdaTarget, lambda_name_id, file_id)
344            .with_qualified_name(lambda_qname_id);
345
346        let lambda_node_id = staging.add_node(lambda_entry);
347        fqn_to_node.insert(lambda_fqn, lambda_node_id);
348        metadata_store.insert_metadata(lambda_node_id, NodeMetadata::Classpath(cp_meta.clone()));
349
350        // Class → LambdaTarget: Contains
351        staging.add_edge(class_node_id, lambda_node_id, EdgeKind::Contains, file_id);
352    }
353
354    // --- Inner classes (Contains edge) ---
355    for inner in &stub.inner_classes {
356        // Only emit Contains edge if the inner class belongs to this outer class
357        // and has been separately emitted (will be linked post-emission).
358        if inner.outer_fqn.as_deref() == Some(&stub.fqn) {
359            // The inner class itself will be emitted as its own ClassStub.
360            // We record the relationship for edge creation in create_classpath_edges.
361            // Store the inner FQN in fqn_to_node so we can link later.
362            // Actual Contains edge is deferred to create_classpath_edges where
363            // both nodes are guaranteed to exist.
364        }
365    }
366
367    // --- Module info ---
368    if let Some(ref module) = stub.module {
369        let mod_name_id = helper.intern(&module.name)?;
370        let mod_fqn = format!("module:{}", module.name);
371        let mod_qname_id = helper.intern(&mod_fqn)?;
372
373        let mod_entry = NodeEntry::new(NodeKind::JavaModule, mod_name_id, file_id)
374            .with_qualified_name(mod_qname_id);
375
376        let mod_node_id = staging.add_node(mod_entry);
377        fqn_to_node.insert(mod_fqn, mod_node_id);
378        metadata_store.insert_metadata(mod_node_id, NodeMetadata::Classpath(cp_meta.clone()));
379
380        // Class → Module: Contains
381        staging.add_edge(class_node_id, mod_node_id, EdgeKind::Contains, file_id);
382    }
383
384    Ok(())
385}
386
387// `find_jar_path_for_stub` has been replaced by `stub.source_jar` inline
388// in `emit_class_stub`. The JAR path is now tracked per-stub during scanning,
389// eliminating the incorrect "first provenance entry" heuristic.
390
391/// Look up provenance for a JAR path.
392fn find_provenance_for_jar<'a>(
393    jar_path: &Path,
394    prov_map: &HashMap<&Path, &'a ClasspathProvenance>,
395) -> Option<&'a ClasspathProvenance> {
396    prov_map.get(jar_path).copied()
397}
398
399// ---------------------------------------------------------------------------
400// ExportMap registration (U15b)
401// ---------------------------------------------------------------------------
402
403/// Register classpath nodes in the ExportMap for cross-file resolution.
404///
405/// FQN precedence: workspace > direct dep > transitive dep.
406/// Direct dependencies are registered before transitive dependencies so that
407/// `ExportMap::lookup()` (which returns the first entry) prefers them.
408///
409/// Only class-level nodes (not methods/fields) are registered, matching the
410/// Java import resolution model where imports resolve to types.
411pub fn register_classpath_exports(
412    fqn_to_node: &HashMap<String, NodeId>,
413    export_map: &mut ExportMap,
414    provenance: &[ClasspathProvenance],
415    file_id_map: &HashMap<String, FileId>,
416    index: &ClasspathIndex,
417) {
418    // Build a set of class-level FQNs (not methods/fields).
419    let class_fqns: std::collections::HashSet<&str> =
420        index.classes.iter().map(|s| s.fqn.as_str()).collect();
421
422    // Register direct dependencies first for precedence.
423    let direct_jars: std::collections::HashSet<&Path> = provenance
424        .iter()
425        .filter(|p| p.is_direct)
426        .map(|p| p.jar_path.as_path())
427        .collect();
428
429    let transitive_jars: std::collections::HashSet<&Path> = provenance
430        .iter()
431        .filter(|p| !p.is_direct)
432        .map(|p| p.jar_path.as_path())
433        .collect();
434
435    // Phase 1: Register direct dependency classes.
436    register_exports_for_jars(
437        &class_fqns,
438        fqn_to_node,
439        export_map,
440        file_id_map,
441        &direct_jars,
442        provenance,
443    );
444
445    // Phase 2: Register transitive dependency classes.
446    register_exports_for_jars(
447        &class_fqns,
448        fqn_to_node,
449        export_map,
450        file_id_map,
451        &transitive_jars,
452        provenance,
453    );
454}
455
456/// Register exports for classes from a specific set of JARs.
457fn register_exports_for_jars(
458    class_fqns: &std::collections::HashSet<&str>,
459    fqn_to_node: &HashMap<String, NodeId>,
460    export_map: &mut ExportMap,
461    file_id_map: &HashMap<String, FileId>,
462    _jar_filter: &std::collections::HashSet<&Path>,
463    _provenance: &[ClasspathProvenance],
464) {
465    for fqn in class_fqns {
466        if let (Some(&node_id), Some(&file_id)) = (fqn_to_node.get(*fqn), file_id_map.get(*fqn)) {
467            export_map.register((*fqn).to_owned(), file_id, node_id);
468        }
469    }
470}
471
472// ---------------------------------------------------------------------------
473// Cross-reference edge creation (U15b)
474// ---------------------------------------------------------------------------
475
476/// Create inheritance, generic, annotation, module, and inner-class edges
477/// for classpath nodes.
478///
479/// Only creates edges where both source and target nodes exist in the graph.
480/// Missing targets are silently skipped (they may be from JARs not on the
481/// classpath).
482#[allow(clippy::too_many_lines)]
483pub fn create_classpath_edges(
484    index: &ClasspathIndex,
485    fqn_to_node: &HashMap<String, NodeId>,
486    staging: &mut StagingGraph,
487    file_id_map: &HashMap<String, FileId>,
488) {
489    for stub in &index.classes {
490        let Some(&class_node_id) = fqn_to_node.get(&stub.fqn) else {
491            continue;
492        };
493        let Some(&file_id) = file_id_map.get(&stub.fqn) else {
494            continue;
495        };
496
497        // --- Inheritance (Inherits) ---
498        if let Some(ref superclass_fqn) = stub.superclass
499            && superclass_fqn != "java.lang.Object"
500        {
501            if let Some(&super_node_id) = fqn_to_node.get(superclass_fqn) {
502                staging.add_edge(class_node_id, super_node_id, EdgeKind::Inherits, file_id);
503            } else {
504                log::debug!(
505                    "classpath: skipping Inherits edge for {} → {} (target not in graph)",
506                    stub.fqn,
507                    superclass_fqn
508                );
509            }
510        }
511
512        // --- Interface implementation (Implements) ---
513        for iface_fqn in &stub.interfaces {
514            if let Some(&iface_node_id) = fqn_to_node.get(iface_fqn) {
515                staging.add_edge(class_node_id, iface_node_id, EdgeKind::Implements, file_id);
516            } else {
517                log::debug!(
518                    "classpath: skipping Implements edge for {} → {} (target not in graph)",
519                    stub.fqn,
520                    iface_fqn
521                );
522            }
523        }
524
525        // --- Generic bounds (GenericBound) ---
526        if let Some(ref gen_sig) = stub.generic_signature {
527            for tp in &gen_sig.type_parameters {
528                let tp_fqn = format!("{}.<{}>", stub.fqn, tp.name);
529                let Some(&tp_node_id) = fqn_to_node.get(&tp_fqn) else {
530                    continue;
531                };
532
533                // Class bound
534                if let Some(ref bound) = tp.class_bound
535                    && let Some(bound_fqn) = extract_class_fqn_from_type_sig(bound)
536                    && let Some(&bound_node_id) = fqn_to_node.get(bound_fqn)
537                {
538                    staging.add_edge(tp_node_id, bound_node_id, EdgeKind::GenericBound, file_id);
539                }
540
541                // Interface bounds
542                for ibound in &tp.interface_bounds {
543                    if let Some(bound_fqn) = extract_class_fqn_from_type_sig(ibound)
544                        && let Some(&bound_node_id) = fqn_to_node.get(bound_fqn)
545                    {
546                        staging.add_edge(
547                            tp_node_id,
548                            bound_node_id,
549                            EdgeKind::GenericBound,
550                            file_id,
551                        );
552                    }
553                }
554            }
555        }
556
557        // --- Annotations (AnnotatedWith) ---
558        for ann in &stub.annotations {
559            if let Some(&ann_type_node_id) = fqn_to_node.get(&ann.type_fqn) {
560                staging.add_edge(
561                    class_node_id,
562                    ann_type_node_id,
563                    EdgeKind::AnnotatedWith,
564                    file_id,
565                );
566            }
567        }
568
569        // Method-level annotations
570        for method in &stub.methods {
571            let method_fqn = format!("{}.{}{}", stub.fqn, method.name, method.descriptor);
572            if let Some(&method_node_id) = fqn_to_node.get(&method_fqn) {
573                for ann in &method.annotations {
574                    if let Some(&ann_type_node_id) = fqn_to_node.get(&ann.type_fqn) {
575                        staging.add_edge(
576                            method_node_id,
577                            ann_type_node_id,
578                            EdgeKind::AnnotatedWith,
579                            file_id,
580                        );
581                    }
582                }
583            }
584        }
585
586        // Field-level annotations
587        for field in &stub.fields {
588            let field_fqn = format!("{}.{}", stub.fqn, field.name);
589            if let Some(&field_node_id) = fqn_to_node.get(&field_fqn) {
590                for ann in &field.annotations {
591                    if let Some(&ann_type_node_id) = fqn_to_node.get(&ann.type_fqn) {
592                        staging.add_edge(
593                            field_node_id,
594                            ann_type_node_id,
595                            EdgeKind::AnnotatedWith,
596                            file_id,
597                        );
598                    }
599                }
600            }
601        }
602
603        // --- Inner classes (Contains) ---
604        for inner in &stub.inner_classes {
605            if inner.outer_fqn.as_deref() == Some(&stub.fqn)
606                && let Some(&inner_node_id) = fqn_to_node.get(&inner.inner_fqn)
607            {
608                staging.add_edge(class_node_id, inner_node_id, EdgeKind::Contains, file_id);
609            }
610        }
611
612        // --- Module edges ---
613        if let Some(ref module) = stub.module {
614            let mod_fqn = format!("module:{}", module.name);
615            let Some(&mod_node_id) = fqn_to_node.get(&mod_fqn) else {
616                continue;
617            };
618
619            // ModuleRequires
620            for req in &module.requires {
621                let req_mod_fqn = format!("module:{}", req.module_name);
622                if let Some(&req_node_id) = fqn_to_node.get(&req_mod_fqn) {
623                    staging.add_edge(mod_node_id, req_node_id, EdgeKind::ModuleRequires, file_id);
624                }
625            }
626
627            // ModuleExports - edge from module to classes in exported package
628            for exp in &module.exports {
629                // Look up classes in the exported package
630                let pkg_classes = index.lookup_package(&exp.package);
631                for pkg_class in pkg_classes {
632                    if let Some(&pkg_class_node_id) = fqn_to_node.get(&pkg_class.fqn) {
633                        staging.add_edge(
634                            mod_node_id,
635                            pkg_class_node_id,
636                            EdgeKind::ModuleExports,
637                            file_id,
638                        );
639                    }
640                }
641            }
642
643            // ModuleOpens - edge from module to classes in opened package
644            for opens in &module.opens {
645                let pkg_classes = index.lookup_package(&opens.package);
646                for pkg_class in pkg_classes {
647                    if let Some(&pkg_class_node_id) = fqn_to_node.get(&pkg_class.fqn) {
648                        staging.add_edge(
649                            mod_node_id,
650                            pkg_class_node_id,
651                            EdgeKind::ModuleOpens,
652                            file_id,
653                        );
654                    }
655                }
656            }
657
658            // ModuleProvides - edge from module to service implementation classes
659            for provides in &module.provides {
660                for impl_fqn in &provides.implementations {
661                    if let Some(&impl_node_id) = fqn_to_node.get(impl_fqn) {
662                        staging.add_edge(
663                            mod_node_id,
664                            impl_node_id,
665                            EdgeKind::ModuleProvides,
666                            file_id,
667                        );
668                    }
669                }
670            }
671        }
672    }
673}
674
675/// Extract the FQN from a `TypeSignature::Class` variant.
676fn extract_class_fqn_from_type_sig(sig: &crate::stub::model::TypeSignature) -> Option<&str> {
677    match sig {
678        crate::stub::model::TypeSignature::Class { fqn, .. } => Some(fqn.as_str()),
679        _ => None,
680    }
681}
682
683// ---------------------------------------------------------------------------
684// Tests
685// ---------------------------------------------------------------------------
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use crate::stub::model::{
691        AccessFlags, AnnotationStub, ClassKind, FieldStub, GenericClassSignature, InnerClassEntry,
692        LambdaTargetStub, MethodStub, ModuleExports, ModuleProvides, ModuleRequires, ModuleStub,
693        ReferenceKind, TypeParameterStub, TypeSignature,
694    };
695
696    // -----------------------------------------------------------------------
697    // Test helpers
698    // -----------------------------------------------------------------------
699
700    fn make_interner() -> StringInterner {
701        StringInterner::new()
702    }
703
704    fn make_staging() -> StagingGraph {
705        StagingGraph::default()
706    }
707
708    fn make_provenance(jar: &str, direct: bool) -> ClasspathProvenance {
709        ClasspathProvenance {
710            jar_path: PathBuf::from(jar),
711            coordinates: Some(format!(
712                "group:artifact:{}",
713                if direct { "1.0" } else { "2.0" }
714            )),
715            is_direct: direct,
716        }
717    }
718
719    fn make_stub(fqn: &str) -> ClassStub {
720        ClassStub {
721            fqn: fqn.to_owned(),
722            name: fqn.rsplit('.').next().unwrap_or(fqn).to_owned(),
723            kind: ClassKind::Class,
724            access: AccessFlags::new(AccessFlags::ACC_PUBLIC),
725            superclass: Some("java.lang.Object".to_owned()),
726            interfaces: vec![],
727            methods: vec![],
728            fields: vec![],
729            annotations: vec![],
730            generic_signature: None,
731            inner_classes: vec![],
732            lambda_targets: vec![],
733            module: None,
734            record_components: vec![],
735            enum_constants: vec![],
736            source_file: None,
737            source_jar: None,
738            kotlin_metadata: None,
739            scala_signature: None,
740        }
741    }
742
743    fn make_method(name: &str) -> MethodStub {
744        MethodStub {
745            name: name.to_owned(),
746            access: AccessFlags::new(AccessFlags::ACC_PUBLIC),
747            descriptor: "()V".to_owned(),
748            generic_signature: None,
749            annotations: vec![],
750            parameter_annotations: vec![],
751            parameter_names: vec![],
752            return_type: TypeSignature::Base(crate::stub::model::BaseType::Void),
753            parameter_types: vec![],
754        }
755    }
756
757    fn make_field(name: &str) -> FieldStub {
758        FieldStub {
759            name: name.to_owned(),
760            access: AccessFlags::new(AccessFlags::ACC_PRIVATE),
761            descriptor: "I".to_owned(),
762            generic_signature: None,
763            annotations: vec![],
764            constant_value: None,
765        }
766    }
767
768    /// Run emission and return the result plus the staging graph for inspection.
769    fn run_emission(
770        stubs: Vec<ClassStub>,
771        provenance: &[ClasspathProvenance],
772    ) -> (
773        EmissionResult,
774        StagingGraph,
775        FileRegistry,
776        StringInterner,
777        NodeMetadataStore,
778    ) {
779        let index = ClasspathIndex::build(stubs);
780        let mut staging = make_staging();
781        let mut file_registry = FileRegistry::new();
782        let mut interner = make_interner();
783        let mut metadata_store = NodeMetadataStore::new();
784
785        let result = emit_classpath_nodes(
786            &index,
787            &mut staging,
788            &mut file_registry,
789            &mut interner,
790            &mut metadata_store,
791            provenance,
792        )
793        .expect("emission should succeed");
794
795        (result, staging, file_registry, interner, metadata_store)
796    }
797
798    // -----------------------------------------------------------------------
799    // Test 1: Simple class emits correct nodes
800    // -----------------------------------------------------------------------
801
802    #[test]
803    fn test_simple_class_emits_nodes() {
804        let mut stub = make_stub("com.example.Foo");
805        stub.methods = vec![make_method("bar"), make_method("baz")];
806        stub.fields = vec![make_field("count")];
807
808        let prov = vec![make_provenance("/jars/example.jar", true)];
809        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
810
811        // Class node
812        assert!(
813            result.fqn_to_node.contains_key("com.example.Foo"),
814            "class node should be emitted"
815        );
816
817        // Method nodes (key includes descriptor for overload disambiguation)
818        assert!(
819            result.fqn_to_node.contains_key("com.example.Foo.bar()V"),
820            "method 'bar' should be emitted"
821        );
822        assert!(
823            result.fqn_to_node.contains_key("com.example.Foo.baz()V"),
824            "method 'baz' should be emitted"
825        );
826
827        // Field node
828        assert!(
829            result.fqn_to_node.contains_key("com.example.Foo.count"),
830            "field 'count' should be emitted"
831        );
832
833        // Total: 1 class + 2 methods + 1 field = 4
834        assert_eq!(result.fqn_to_node.len(), 4);
835    }
836
837    // -----------------------------------------------------------------------
838    // Test 2: Inheritance edge
839    // -----------------------------------------------------------------------
840
841    #[test]
842    fn test_inheritance_edge_created() {
843        let mut list = make_stub("java.util.AbstractList");
844        list.superclass = Some("java.util.AbstractCollection".to_owned());
845
846        let collection = make_stub("java.util.AbstractCollection");
847
848        let prov = vec![make_provenance("/jars/rt.jar", true)];
849        let stubs = vec![list, collection];
850        let index = ClasspathIndex::build(stubs.clone());
851        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
852
853        create_classpath_edges(
854            &index,
855            &result.fqn_to_node,
856            &mut staging,
857            &result.file_id_map,
858        );
859
860        // Verify the AbstractList node exists
861        assert!(result.fqn_to_node.contains_key("java.util.AbstractList"));
862        assert!(
863            result
864                .fqn_to_node
865                .contains_key("java.util.AbstractCollection")
866        );
867
868        // Edge verification happens through staging stats
869        let stats = staging.stats();
870        // Structural edges (Defines for methods/fields) + Inherits edge
871        assert!(
872            stats.edges_staged > 0,
873            "should have at least one edge staged"
874        );
875    }
876
877    // -----------------------------------------------------------------------
878    // Test 3: Interface implementation edge
879    // -----------------------------------------------------------------------
880
881    #[test]
882    fn test_implements_edge_created() {
883        let mut array_list = make_stub("java.util.ArrayList");
884        array_list.interfaces = vec![
885            "java.util.List".to_owned(),
886            "java.io.Serializable".to_owned(),
887        ];
888
889        let list_iface = {
890            let mut s = make_stub("java.util.List");
891            s.kind = ClassKind::Interface;
892            s
893        };
894
895        let serializable = {
896            let mut s = make_stub("java.io.Serializable");
897            s.kind = ClassKind::Interface;
898            s
899        };
900
901        let prov = vec![make_provenance("/jars/rt.jar", true)];
902        let stubs = vec![array_list, list_iface, serializable];
903        let index = ClasspathIndex::build(stubs.clone());
904        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
905
906        create_classpath_edges(
907            &index,
908            &result.fqn_to_node,
909            &mut staging,
910            &result.file_id_map,
911        );
912
913        // All three should exist
914        assert!(result.fqn_to_node.contains_key("java.util.ArrayList"));
915        assert!(result.fqn_to_node.contains_key("java.util.List"));
916        assert!(result.fqn_to_node.contains_key("java.io.Serializable"));
917    }
918
919    // -----------------------------------------------------------------------
920    // Test 4: ExportMap registration and lookup
921    // -----------------------------------------------------------------------
922
923    #[test]
924    fn test_export_map_registration_and_lookup() {
925        let stubs = vec![
926            make_stub("com.example.Alpha"),
927            make_stub("com.example.Beta"),
928        ];
929
930        let prov = vec![make_provenance("/jars/example.jar", true)];
931        let index = ClasspathIndex::build(stubs.clone());
932        let (result, _staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
933
934        let mut export_map = ExportMap::new();
935        register_classpath_exports(
936            &result.fqn_to_node,
937            &mut export_map,
938            &prov,
939            &result.file_id_map,
940            &index,
941        );
942
943        // Lookup should find the registered classes
944        let alpha = export_map.lookup("com.example.Alpha");
945        assert!(alpha.is_some(), "Alpha should be in ExportMap");
946
947        let beta = export_map.lookup("com.example.Beta");
948        assert!(beta.is_some(), "Beta should be in ExportMap");
949
950        // Non-existent should return None
951        let missing = export_map.lookup("com.example.DoesNotExist");
952        assert!(missing.is_none());
953    }
954
955    // -----------------------------------------------------------------------
956    // Test 5: FQN precedence (direct > transitive)
957    // -----------------------------------------------------------------------
958
959    #[test]
960    fn test_fqn_precedence_direct_before_transitive() {
961        // Two stubs with same FQN but from different JARs won't happen in practice
962        // since ClasspathIndex deduplicates. But we verify direct deps register first.
963        let stub = make_stub("com.example.Foo");
964        let prov_direct = make_provenance("/jars/direct.jar", true);
965        let prov_transitive = make_provenance("/jars/transitive.jar", false);
966
967        let index = ClasspathIndex::build(vec![stub]);
968        let (result, _staging, _registry, _interner, _meta) = run_emission(
969            index.classes.clone(),
970            &[prov_direct.clone(), prov_transitive],
971        );
972
973        let mut export_map = ExportMap::new();
974        register_classpath_exports(
975            &result.fqn_to_node,
976            &mut export_map,
977            &[prov_direct, make_provenance("/jars/transitive.jar", false)],
978            &result.file_id_map,
979            &index,
980        );
981
982        // Should find the class
983        assert!(export_map.lookup("com.example.Foo").is_some());
984    }
985
986    // -----------------------------------------------------------------------
987    // Test 6: ClasspathNodeMetadata attached correctly
988    // -----------------------------------------------------------------------
989
990    #[test]
991    fn test_classpath_metadata_attached() {
992        let stub = make_stub("com.google.common.collect.ImmutableList");
993        let prov = vec![ClasspathProvenance {
994            jar_path: PathBuf::from("/home/user/.m2/repository/guava-33.0.0.jar"),
995            coordinates: Some("com.google.guava:guava:33.0.0".to_owned()),
996            is_direct: true,
997        }];
998
999        let (result, _staging, _registry, _interner, metadata_store) =
1000            run_emission(vec![stub], &prov);
1001
1002        let node_id = result
1003            .fqn_to_node
1004            .get("com.google.common.collect.ImmutableList")
1005            .expect("node should exist");
1006
1007        let metadata = metadata_store
1008            .get_metadata(*node_id)
1009            .expect("metadata should be attached");
1010
1011        match metadata {
1012            NodeMetadata::Classpath(cp) => {
1013                assert_eq!(cp.fqn, "com.google.common.collect.ImmutableList");
1014                assert_eq!(
1015                    cp.coordinates.as_deref(),
1016                    Some("com.google.guava:guava:33.0.0")
1017                );
1018                assert!(cp.is_direct_dependency);
1019                assert!(cp.jar_path.contains("guava-33.0.0.jar"));
1020            }
1021            NodeMetadata::Macro(_) => panic!("expected Classpath metadata, got Macro"),
1022        }
1023    }
1024
1025    // -----------------------------------------------------------------------
1026    // Test 7: Zero spans on all nodes
1027    // -----------------------------------------------------------------------
1028
1029    #[test]
1030    fn test_zero_spans_on_classpath_nodes() {
1031        let mut stub = make_stub("com.example.ZeroSpan");
1032        stub.methods = vec![make_method("doWork")];
1033
1034        let prov = vec![make_provenance("/jars/test.jar", true)];
1035        let (result, staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
1036
1037        // All emitted nodes should have zero spans since they come from bytecode.
1038        // The NodeEntry::new constructor sets all span fields to 0 by default,
1039        // and we don't override them.
1040        assert!(
1041            !result.fqn_to_node.is_empty(),
1042            "should have emitted at least one node"
1043        );
1044
1045        // Verify through staging stats that nodes were emitted
1046        let stats = staging.stats();
1047        assert!(
1048            stats.nodes_staged >= 2,
1049            "should have at least 2 nodes (class + method)"
1050        );
1051    }
1052
1053    // -----------------------------------------------------------------------
1054    // Test 8: Annotation edges
1055    // -----------------------------------------------------------------------
1056
1057    #[test]
1058    fn test_annotation_edges() {
1059        let mut stub = make_stub("com.example.MyService");
1060        stub.annotations = vec![AnnotationStub {
1061            type_fqn: "org.springframework.stereotype.Service".to_owned(),
1062            elements: vec![],
1063            is_runtime_visible: true,
1064        }];
1065
1066        // Also emit the annotation type so the edge can be created
1067        let ann_type = {
1068            let mut s = make_stub("org.springframework.stereotype.Service");
1069            s.kind = ClassKind::Annotation;
1070            s
1071        };
1072
1073        let prov = vec![make_provenance("/jars/app.jar", true)];
1074        let stubs = vec![stub, ann_type];
1075        let index = ClasspathIndex::build(stubs.clone());
1076        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1077
1078        create_classpath_edges(
1079            &index,
1080            &result.fqn_to_node,
1081            &mut staging,
1082            &result.file_id_map,
1083        );
1084
1085        // Both nodes should exist
1086        assert!(result.fqn_to_node.contains_key("com.example.MyService"));
1087        assert!(
1088            result
1089                .fqn_to_node
1090                .contains_key("org.springframework.stereotype.Service")
1091        );
1092
1093        // Edge verification through staging stats
1094        let stats = staging.stats();
1095        assert!(
1096            stats.edges_staged > 0,
1097            "should have annotation edges staged"
1098        );
1099    }
1100
1101    // -----------------------------------------------------------------------
1102    // Test 9: Module edges
1103    // -----------------------------------------------------------------------
1104
1105    #[test]
1106    fn test_module_edges() {
1107        let provider_class = make_stub("com.example.spi.MyProvider");
1108        let exported_class = make_stub("com.example.api.MyApi");
1109
1110        let mut module_stub = make_stub("module-info");
1111        module_stub.kind = ClassKind::Module;
1112        module_stub.module = Some(ModuleStub {
1113            name: "com.example".to_owned(),
1114            access: AccessFlags::new(0),
1115            version: None,
1116            requires: vec![ModuleRequires {
1117                module_name: "java.base".to_owned(),
1118                access: AccessFlags::new(0),
1119                version: None,
1120            }],
1121            exports: vec![ModuleExports {
1122                package: "com.example.api".to_owned(),
1123                access: AccessFlags::new(0),
1124                to_modules: vec![],
1125            }],
1126            opens: vec![],
1127            provides: vec![ModuleProvides {
1128                service: "com.example.spi.SomeService".to_owned(),
1129                implementations: vec!["com.example.spi.MyProvider".to_owned()],
1130            }],
1131            uses: vec![],
1132        });
1133
1134        let prov = vec![make_provenance("/jars/example.jar", true)];
1135        let stubs = vec![module_stub, provider_class, exported_class];
1136        let index = ClasspathIndex::build(stubs.clone());
1137        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1138
1139        create_classpath_edges(
1140            &index,
1141            &result.fqn_to_node,
1142            &mut staging,
1143            &result.file_id_map,
1144        );
1145
1146        // Module node should exist
1147        assert!(
1148            result.fqn_to_node.contains_key("module:com.example"),
1149            "module node should be emitted"
1150        );
1151
1152        let stats = staging.stats();
1153        assert!(stats.edges_staged > 0, "should have module edges staged");
1154    }
1155
1156    // -----------------------------------------------------------------------
1157    // Test 10: Enum constants
1158    // -----------------------------------------------------------------------
1159
1160    #[test]
1161    fn test_enum_constants_emitted() {
1162        let mut stub = make_stub("java.time.DayOfWeek");
1163        stub.kind = ClassKind::Enum;
1164        stub.enum_constants = vec![
1165            "MONDAY".to_owned(),
1166            "TUESDAY".to_owned(),
1167            "WEDNESDAY".to_owned(),
1168        ];
1169
1170        let prov = vec![make_provenance("/jars/rt.jar", true)];
1171        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
1172
1173        assert!(
1174            result
1175                .fqn_to_node
1176                .contains_key("java.time.DayOfWeek.MONDAY")
1177        );
1178        assert!(
1179            result
1180                .fqn_to_node
1181                .contains_key("java.time.DayOfWeek.TUESDAY")
1182        );
1183        assert!(
1184            result
1185                .fqn_to_node
1186                .contains_key("java.time.DayOfWeek.WEDNESDAY")
1187        );
1188    }
1189
1190    // -----------------------------------------------------------------------
1191    // Test 11: Type parameters emitted
1192    // -----------------------------------------------------------------------
1193
1194    #[test]
1195    fn test_type_parameters_emitted() {
1196        let mut stub = make_stub("java.util.HashMap");
1197        stub.generic_signature = Some(GenericClassSignature {
1198            type_parameters: vec![
1199                TypeParameterStub {
1200                    name: "K".to_owned(),
1201                    class_bound: None,
1202                    interface_bounds: vec![],
1203                },
1204                TypeParameterStub {
1205                    name: "V".to_owned(),
1206                    class_bound: None,
1207                    interface_bounds: vec![],
1208                },
1209            ],
1210            superclass: TypeSignature::Class {
1211                fqn: "java.util.AbstractMap".to_owned(),
1212                type_arguments: vec![],
1213            },
1214            interfaces: vec![],
1215        });
1216
1217        let prov = vec![make_provenance("/jars/rt.jar", true)];
1218        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
1219
1220        assert!(result.fqn_to_node.contains_key("java.util.HashMap.<K>"));
1221        assert!(result.fqn_to_node.contains_key("java.util.HashMap.<V>"));
1222    }
1223
1224    // -----------------------------------------------------------------------
1225    // Test 12: Generic bound edges
1226    // -----------------------------------------------------------------------
1227
1228    #[test]
1229    fn test_generic_bound_edges() {
1230        let comparable = make_stub("java.lang.Comparable");
1231
1232        let mut stub = make_stub("com.example.Sorted");
1233        stub.generic_signature = Some(GenericClassSignature {
1234            type_parameters: vec![TypeParameterStub {
1235                name: "T".to_owned(),
1236                class_bound: Some(TypeSignature::Class {
1237                    fqn: "java.lang.Comparable".to_owned(),
1238                    type_arguments: vec![],
1239                }),
1240                interface_bounds: vec![],
1241            }],
1242            superclass: TypeSignature::Class {
1243                fqn: "java.lang.Object".to_owned(),
1244                type_arguments: vec![],
1245            },
1246            interfaces: vec![],
1247        });
1248
1249        let prov = vec![make_provenance("/jars/rt.jar", true)];
1250        let stubs = vec![stub, comparable];
1251        let index = ClasspathIndex::build(stubs.clone());
1252        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1253
1254        create_classpath_edges(
1255            &index,
1256            &result.fqn_to_node,
1257            &mut staging,
1258            &result.file_id_map,
1259        );
1260
1261        // Type parameter and bound target should exist
1262        assert!(result.fqn_to_node.contains_key("com.example.Sorted.<T>"));
1263        assert!(result.fqn_to_node.contains_key("java.lang.Comparable"));
1264    }
1265
1266    // -----------------------------------------------------------------------
1267    // Test 13: Lambda targets emitted
1268    // -----------------------------------------------------------------------
1269
1270    #[test]
1271    fn test_lambda_targets_emitted() {
1272        let mut stub = make_stub("com.example.Processor");
1273        stub.lambda_targets = vec![LambdaTargetStub {
1274            owner_fqn: "java.lang.String".to_owned(),
1275            method_name: "toUpperCase".to_owned(),
1276            method_descriptor: "()Ljava/lang/String;".to_owned(),
1277            reference_kind: ReferenceKind::InvokeVirtual,
1278        }];
1279
1280        let prov = vec![make_provenance("/jars/app.jar", true)];
1281        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
1282
1283        assert!(
1284            result
1285                .fqn_to_node
1286                .contains_key("com.example.Processor.lambda$toUpperCase")
1287        );
1288    }
1289
1290    // -----------------------------------------------------------------------
1291    // Test 14: Inner class Contains edge
1292    // -----------------------------------------------------------------------
1293
1294    #[test]
1295    fn test_inner_class_contains_edge() {
1296        let outer = {
1297            let mut s = make_stub("com.example.Outer");
1298            s.inner_classes = vec![InnerClassEntry {
1299                inner_fqn: "com.example.Outer.Inner".to_owned(),
1300                outer_fqn: Some("com.example.Outer".to_owned()),
1301                inner_name: Some("Inner".to_owned()),
1302                access: AccessFlags::new(AccessFlags::ACC_PUBLIC),
1303            }];
1304            s
1305        };
1306
1307        let inner = make_stub("com.example.Outer.Inner");
1308
1309        let prov = vec![make_provenance("/jars/app.jar", true)];
1310        let stubs = vec![outer, inner];
1311        let index = ClasspathIndex::build(stubs.clone());
1312        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1313
1314        create_classpath_edges(
1315            &index,
1316            &result.fqn_to_node,
1317            &mut staging,
1318            &result.file_id_map,
1319        );
1320
1321        assert!(result.fqn_to_node.contains_key("com.example.Outer"));
1322        assert!(result.fqn_to_node.contains_key("com.example.Outer.Inner"));
1323    }
1324
1325    // -----------------------------------------------------------------------
1326    // Test 15: Empty index produces empty result
1327    // -----------------------------------------------------------------------
1328
1329    #[test]
1330    fn test_empty_index_no_nodes() {
1331        let prov: Vec<ClasspathProvenance> = vec![];
1332        let (result, staging, _registry, _interner, _meta) = run_emission(vec![], &prov);
1333
1334        assert!(result.fqn_to_node.is_empty());
1335        assert_eq!(staging.stats().nodes_staged, 0);
1336    }
1337
1338    // -----------------------------------------------------------------------
1339    // Test 16: Synthetic file path convention
1340    // -----------------------------------------------------------------------
1341
1342    #[test]
1343    fn test_synthetic_file_path_convention() {
1344        let stub = make_stub("com.example.Foo");
1345        let prov = vec![make_provenance("/jars/example.jar", true)];
1346        let (result, _staging, file_registry, _interner, _meta) = run_emission(vec![stub], &prov);
1347
1348        let file_id = result
1349            .file_id_map
1350            .get("com.example.Foo")
1351            .expect("file ID should exist");
1352
1353        let path = file_registry
1354            .resolve(*file_id)
1355            .expect("file should be resolvable");
1356
1357        let path_str = path.to_string_lossy();
1358        assert!(
1359            path_str.contains("!/com/example/Foo.class"),
1360            "synthetic path should follow JAR convention, got: {path_str}"
1361        );
1362    }
1363
1364    // -----------------------------------------------------------------------
1365    // Test 17: Interface kind maps correctly
1366    // -----------------------------------------------------------------------
1367
1368    #[test]
1369    fn test_interface_kind_mapping() {
1370        let mut stub = make_stub("java.util.List");
1371        stub.kind = ClassKind::Interface;
1372
1373        let prov = vec![make_provenance("/jars/rt.jar", true)];
1374        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
1375
1376        assert!(result.fqn_to_node.contains_key("java.util.List"));
1377    }
1378
1379    // -----------------------------------------------------------------------
1380    // Test 18: Method-level annotation edge
1381    // -----------------------------------------------------------------------
1382
1383    #[test]
1384    fn test_method_level_annotation_edge() {
1385        let override_ann = {
1386            let mut s = make_stub("java.lang.Override");
1387            s.kind = ClassKind::Annotation;
1388            s
1389        };
1390
1391        let mut stub = make_stub("com.example.Foo");
1392        stub.methods = vec![MethodStub {
1393            name: "toString".to_owned(),
1394            access: AccessFlags::new(AccessFlags::ACC_PUBLIC),
1395            descriptor: "()Ljava/lang/String;".to_owned(),
1396            generic_signature: None,
1397            annotations: vec![AnnotationStub {
1398                type_fqn: "java.lang.Override".to_owned(),
1399                elements: vec![],
1400                is_runtime_visible: true,
1401            }],
1402            parameter_annotations: vec![],
1403            parameter_names: vec![],
1404            return_type: TypeSignature::Base(crate::stub::model::BaseType::Void),
1405            parameter_types: vec![],
1406        }];
1407
1408        let prov = vec![make_provenance("/jars/rt.jar", true)];
1409        let stubs = vec![stub, override_ann];
1410        let index = ClasspathIndex::build(stubs.clone());
1411        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1412
1413        create_classpath_edges(
1414            &index,
1415            &result.fqn_to_node,
1416            &mut staging,
1417            &result.file_id_map,
1418        );
1419
1420        assert!(
1421            result
1422                .fqn_to_node
1423                .contains_key("com.example.Foo.toString()Ljava/lang/String;")
1424        );
1425        assert!(result.fqn_to_node.contains_key("java.lang.Override"));
1426    }
1427
1428    // -----------------------------------------------------------------------
1429    // Test 19: No provenance still emits nodes
1430    // -----------------------------------------------------------------------
1431
1432    #[test]
1433    fn test_no_provenance_still_emits() {
1434        let stub = make_stub("com.example.NoProv");
1435        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &[]);
1436
1437        assert!(result.fqn_to_node.contains_key("com.example.NoProv"));
1438    }
1439
1440    // -----------------------------------------------------------------------
1441    // Test 20: Visibility mapping
1442    // -----------------------------------------------------------------------
1443
1444    #[test]
1445    fn test_visibility_mapping() {
1446        assert_eq!(
1447            access_to_visibility(&AccessFlags::new(AccessFlags::ACC_PUBLIC)),
1448            "public"
1449        );
1450        assert_eq!(
1451            access_to_visibility(&AccessFlags::new(AccessFlags::ACC_PROTECTED)),
1452            "protected"
1453        );
1454        assert_eq!(
1455            access_to_visibility(&AccessFlags::new(AccessFlags::ACC_PRIVATE)),
1456            "private"
1457        );
1458        assert_eq!(access_to_visibility(&AccessFlags::empty()), "package");
1459    }
1460}