diff --git a/CHANGELOG.md b/CHANGELOG.md index d9c6fbb5..bcbb6755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features - `zcodegraph status` and `zcodegraph doctor` now share graph health language, so users and scripts can distinguish healthy, degraded, stale, failed, unavailable, and corrupted indexes with exact next-step commands. +- `rust-hybrid` indexing now handles Java baseline symbols through the Rust-owned path, so Java classes, interfaces, annotations, enums, imports, and method calls no longer depend on TypeScript fallback extraction. - `rust-hybrid` indexing now preserves Python decorator relationships through the Rust-owned path, so decorated Python functions and methods can appear in graph impact and traversal results. - `rust-hybrid` indexing now understands Python calls, imported names, and module-level values, so Python search, callers/callees, and file dependents work through the Rust indexer. - `zcodegraph status` now shows decision-ready `rust-hybrid` fallback diagnostics, including top reason groups, graph usability, the exact doctor command, and the privacy-preserving bundle artifact to share with maintainers. (#668, #669, #670) diff --git a/Cargo.lock b/Cargo.lock index 5fe6836a..8d775270 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -484,6 +484,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-javascript" version = "0.23.1" @@ -572,6 +582,7 @@ dependencies = [ "sha2", "tree-sitter", "tree-sitter-go", + "tree-sitter-java", "tree-sitter-javascript", "tree-sitter-python", "tree-sitter-rust", diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index fb2729f4..54740c9e 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -437,7 +437,7 @@ describe('Java end-to-end — field-injected bean trace (issue #389)', () => { ); const cg = CodeGraph.initSync(tmpDir); - await cg.indexAll({ engine: 'typescript' }); + await cg.indexAll({ engine: 'rust-hybrid' }); const methods = cg.getNodesByKind('method'); const find = (cls: string, name: string) => @@ -503,7 +503,7 @@ describe('Java end-to-end — field-injected bean trace (issue #389)', () => { ); const cg = CodeGraph.initSync(tmpDir); - await cg.indexAll({ engine: 'typescript' }); + await cg.indexAll({ engine: 'rust-hybrid' }); const methods = cg.getNodesByKind('method'); const getByIdJava = methods.find((m) => m.name === 'getById' && m.language === 'java'); @@ -665,7 +665,7 @@ describe('Java end-to-end — field-injected bean trace (issue #389)', () => { ); const cg = CodeGraph.initSync(tmpDir); - await cg.indexAll({ engine: 'typescript' }); + await cg.indexAll({ engine: 'rust-hybrid' }); const methods = cg.getNodesByKind('method'); const go = methods.find((m) => m.name === 'go'); @@ -752,7 +752,7 @@ describe('JVM FQN imports — end-to-end', () => { ); const cg = CodeGraph.initSync(tmpDir); - await cg.indexAll({ engine: 'typescript' }); + await cg.indexAll({ engine: 'rust-hybrid' }); const javaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example::JavaBar'); expect(javaBar, 'JavaBar should be extracted under com.example regardless of language').toBeDefined(); @@ -840,7 +840,7 @@ describe('Java anonymous-class override synthesis — end-to-end', () => { ); const cg = CodeGraph.initSync(tmpDir); - await cg.indexAll({ engine: 'typescript' }); + await cg.indexAll({ engine: 'rust-hybrid' }); // The anon class is extracted and contains the override. const anonClass = cg diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 023cfd62..f5da8f53 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1385,7 +1385,7 @@ public class Handler { ` ); - cg = await CodeGraph.init(tempDir, { index: true, engine: 'typescript' }); + cg = await CodeGraph.init(tempDir, { index: true, engine: 'rust-hybrid' }); const use = cg .getNodesByKind('method') diff --git a/__tests__/rust-index-engine-cli-language-smoke.test.ts b/__tests__/rust-index-engine-cli-language-smoke.test.ts index 0895bc33..783c909c 100644 --- a/__tests__/rust-index-engine-cli-language-smoke.test.ts +++ b/__tests__/rust-index-engine-cli-language-smoke.test.ts @@ -186,6 +186,98 @@ describe('zcodegraph rust index language framework and MCP smoke behavior', () = } }, 30_000); + it('indexes Java as Rust-owned under rust-hybrid', () => { + const javaDir = path.join(tempDir, 'src/main/java/com/example'); + fs.mkdirSync(javaDir, { recursive: true }); + fs.writeFileSync( + path.join(javaDir, 'Worker.java'), + [ + 'package com.example;', + '', + 'public interface Worker {', + ' void run();', + '}', + ].join('\n') + '\n', + ); + fs.writeFileSync( + path.join(javaDir, 'Service.java'), + [ + 'package com.example;', + '', + 'import java.util.List;', + '', + 'public class Service {', + ' private Worker worker;', + '', + ' public Service(Worker worker) {', + ' this.worker = worker;', + ' }', + '', + ' public void handle() {', + ' int result = assist();', + ' worker.run();', + ' }', + '', + ' private int assist() {', + ' return List.of(1).size();', + ' }', + '', + ' enum Mode { FAST }', + '}', + '', + '@interface CustomMapping {', + ' String value();', + '}', + ].join('\n') + '\n', + ); + + const result = runZcodegraphCli(tempDir, ['index', '--quiet'], { + ZCODEGRAPH_RUST_CORE_BINARY: RUST_CORE_BIN, + }); + + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + const cg = CodeGraph.openSync(tempDir); + try { + expect(cg.getStats().filesByLanguage.java).toBe(2); + const expectations = [ + ['com.example', 'module'], + ['java.util.List', 'import'], + ['Worker', 'interface'], + ['Service', 'class'], + ['worker', 'field'], + ['Service', 'method'], + ['handle', 'method'], + ['result', 'variable'], + ['assist', 'method'], + ['Mode', 'enum'], + ['FAST', 'enum_member'], + ['CustomMapping', 'interface'], + ['value', 'method'], + ] as const; + for (const [name, kind] of expectations) { + expect( + cg.searchNodes(name).some((match) => match.node.name === name && match.node.kind === kind && match.node.language === 'java'), + `${name} (${kind}) should be indexed as Java`, + ).toBe(true); + } + + const assist = cg.searchNodes('assist').find((match) => match.node.kind === 'method')?.node; + expect(assist?.qualifiedName).toContain('Service::assist'); + + const buildInfo = cg.getIndexBuildInfo(); + expect(buildInfo.engine).toBe('rust-hybrid'); + expect(buildInfo.hybrid).toMatchObject({ + rustOwnedLanguages: expect.arrayContaining(['java']), + engineByLanguage: { java: 'rust' }, + fallbackByLanguage: {}, + fallbackFileCount: 0, + fallbackReasonTaxonomy: {}, + }); + } finally { + cg.close(); + } + }, 30_000); + it('reports Rust index-engine metadata through MCP status', async () => { const indexResult = runZcodegraphCli(tempDir, ['index', '--engine', 'rust', '--quiet'], { ZCODEGRAPH_RUST_CORE_BINARY: RUST_CORE_BIN, diff --git a/crates/zcodegraph-core/Cargo.toml b/crates/zcodegraph-core/Cargo.toml index e9f64731..c49a90f0 100644 --- a/crates/zcodegraph-core/Cargo.toml +++ b/crates/zcodegraph-core/Cargo.toml @@ -13,6 +13,7 @@ serde_json = "1" sha2 = "0.10" tree-sitter = "0.24" tree-sitter-go = "0.23" +tree-sitter-java = "0.23" tree-sitter-javascript = "0.23" tree-sitter-python = "0.23" tree-sitter-rust = "0.23" diff --git a/crates/zcodegraph-core/src/lib.rs b/crates/zcodegraph-core/src/lib.rs index 2c7c253e..fbb200b6 100644 --- a/crates/zcodegraph-core/src/lib.rs +++ b/crates/zcodegraph-core/src/lib.rs @@ -7164,6 +7164,16 @@ fn index_javascript_files( &mut edges, &mut unresolved_refs, )?; + } else if language.is_java() { + extract_java_symbols( + parsed.root_node(), + content.as_bytes(), + &relative_path, + &file_node_id, + &mut nodes, + &mut edges, + &mut unresolved_refs, + )?; } else if language.is_python() { extract_python_symbols( parsed.root_node(), @@ -7491,6 +7501,7 @@ enum SourceLanguage { Mts, Cts, Go, + Java, Python, Rust, } @@ -7505,6 +7516,7 @@ impl SourceLanguage { Some("mts") => Some(Self::Mts), Some("cts") => Some(Self::Cts), Some("go") => Some(Self::Go), + Some("java") => Some(Self::Java), Some("py") | Some("pyw") => Some(Self::Python), Some("rs") => Some(Self::Rust), _ => None, @@ -7518,6 +7530,7 @@ impl SourceLanguage { Self::TypeScript | Self::Mts | Self::Cts => "typescript", Self::Tsx => "tsx", Self::Go => "go", + Self::Java => "java", Self::Python => "python", Self::Rust => "rust", } @@ -7531,6 +7544,7 @@ impl SourceLanguage { } Self::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(), Self::Go => tree_sitter_go::LANGUAGE.into(), + Self::Java => tree_sitter_java::LANGUAGE.into(), Self::Python => tree_sitter_python::LANGUAGE.into(), Self::Rust => tree_sitter_rust::LANGUAGE.into(), } @@ -7544,6 +7558,10 @@ impl SourceLanguage { matches!(self, Self::Go) } + fn is_java(self) -> bool { + matches!(self, Self::Java) + } + fn is_python(self) -> bool { matches!(self, Self::Python) } @@ -8525,6 +8543,527 @@ fn go_type_name(node: SyntaxNode, source: &[u8]) -> Option { } } +fn extract_java_symbols( + root: SyntaxNode, + source: &[u8], + relative_path: &str, + file_node_id: &str, + nodes: &mut Vec, + edges: &mut Vec, + unresolved_refs: &mut Vec, +) -> Result<(), Box> { + let package = find_java_package(root, source)?; + let mut base_from_node_id: Cow<'_, str> = Cow::Borrowed(file_node_id); + let mut qualified_base: Cow<'_, str> = Cow::Borrowed(relative_path); + if let Some((package_name, package_node)) = package { + let module_node = ExtractedNode::symbol_with_qualified_name( + relative_path, + "module", + &package_name, + package_node, + "java", + package_name.clone(), + ); + let module_node_id = module_node.id.clone(); + edges.push(ExtractedEdge { + source: file_node_id.to_string(), + target: module_node_id.clone(), + kind: "contains".to_string(), + line: module_node.start_line, + col: module_node.start_column, + }); + nodes.push(module_node); + base_from_node_id = Cow::Owned(module_node_id); + qualified_base = Cow::Owned(package_name); + } + + let mut cursor = root.walk(); + visit_java_node( + &mut cursor, + source, + relative_path, + file_node_id, + &base_from_node_id, + &qualified_base, + &[], + nodes, + edges, + unresolved_refs, + )?; + Ok(()) +} + +fn visit_java_node( + cursor: &mut TreeCursor, + source: &[u8], + relative_path: &str, + file_node_id: &str, + current_from_node_id: &str, + qualified_base: &str, + container_stack: &[String], + nodes: &mut Vec, + edges: &mut Vec, + unresolved_refs: &mut Vec, +) -> Result<(), Box> { + let node = cursor.node(); + let mut child_from_node_id: Cow<'_, str> = Cow::Borrowed(current_from_node_id); + let mut child_container_stack = container_stack.to_vec(); + + if let Some(symbol) = extract_java_named_symbol(node, source)? { + let qualified_name = + java_qualified_name(qualified_base, &symbol.name, &child_container_stack); + let mut extracted = ExtractedNode::symbol_with_qualified_name( + relative_path, + symbol.kind, + &symbol.name, + node, + "java", + qualified_name, + ); + extracted.signature = symbol.signature; + let extracted_id = extracted.id.clone(); + let contains_source = if current_from_node_id != file_node_id { + current_from_node_id + } else { + file_node_id + }; + edges.push(ExtractedEdge { + source: contains_source.to_string(), + target: extracted_id.clone(), + kind: "contains".to_string(), + line: extracted.start_line, + col: extracted.start_column, + }); + nodes.push(extracted); + if let Some(extends_name) = symbol.extends_name.as_deref() { + push_ref( + unresolved_refs, + &extracted_id, + extends_name, + "extends", + node, + relative_path, + SourceLanguage::Java, + ); + } + for relation in java_type_relation_refs(node, source)? { + push_ref( + unresolved_refs, + &extracted_id, + &relation.name, + relation.kind, + node, + relative_path, + SourceLanguage::Java, + ); + } + + if matches!(symbol.kind, "class" | "interface" | "enum" | "method") { + child_from_node_id = Cow::Owned(extracted_id); + } + if matches!(symbol.kind, "class" | "interface" | "enum" | "method") { + child_container_stack.push(symbol.name); + } + } + + extract_java_imports( + node, + source, + relative_path, + current_from_node_id, + nodes, + edges, + unresolved_refs, + )?; + extract_java_statement_refs( + node, + source, + relative_path, + current_from_node_id, + unresolved_refs, + )?; + + if cursor.goto_first_child() { + loop { + visit_java_node( + cursor, + source, + relative_path, + file_node_id, + &child_from_node_id, + qualified_base, + &child_container_stack, + nodes, + edges, + unresolved_refs, + )?; + if !cursor.goto_next_sibling() { + break; + } + } + cursor.goto_parent(); + } + + Ok(()) +} + +struct JavaSymbolCandidate { + kind: &'static str, + name: String, + extends_name: Option, + signature: Option, +} + +struct JavaTypeRelation { + kind: &'static str, + name: String, +} + +fn extract_java_named_symbol( + node: SyntaxNode, + source: &[u8], +) -> Result, Box> { + match node.kind() { + "package_declaration" => { + return Ok(None); + } + "class_declaration" => { + if let Some(name_node) = node.child_by_field_name("name") { + return Ok(Some(JavaSymbolCandidate { + kind: "class", + name: name_node.utf8_text(source)?.to_string(), + extends_name: None, + signature: None, + })); + } + } + "interface_declaration" | "annotation_type_declaration" => { + if let Some(name_node) = node.child_by_field_name("name") { + return Ok(Some(JavaSymbolCandidate { + kind: "interface", + name: name_node.utf8_text(source)?.to_string(), + extends_name: None, + signature: None, + })); + } + } + "enum_declaration" => { + if let Some(name_node) = node.child_by_field_name("name") { + return Ok(Some(JavaSymbolCandidate { + kind: "enum", + name: name_node.utf8_text(source)?.to_string(), + extends_name: None, + signature: None, + })); + } + } + "object_creation_expression" if java_anonymous_class_body(node).is_some() => { + let (type_name, line) = java_object_creation_type_name(node, source)? + .unwrap_or_else(|| ("Object".to_string(), (node.start_position().row + 1) as i64)); + return Ok(Some(JavaSymbolCandidate { + kind: "class", + name: format!("<{type_name}$anon@{line}>"), + extends_name: Some(type_name), + signature: None, + })); + } + "enum_constant" => { + if let Some(name_node) = node + .child_by_field_name("name") + .or_else(|| node.named_child(0)) + { + return Ok(Some(JavaSymbolCandidate { + kind: "enum_member", + name: name_node.utf8_text(source)?.to_string(), + extends_name: None, + signature: None, + })); + } + } + "method_declaration" + | "constructor_declaration" + | "annotation_type_element_declaration" => { + if let Some(name_node) = node.child_by_field_name("name") { + return Ok(Some(JavaSymbolCandidate { + kind: "method", + name: name_node.utf8_text(source)?.to_string(), + extends_name: None, + signature: None, + })); + } + } + "field_declaration" => { + if let Some(name) = java_variable_declarator_name(node, source)? { + let signature = java_variable_signature(node, source, &name)?; + return Ok(Some(JavaSymbolCandidate { + kind: "field", + name, + extends_name: None, + signature, + })); + } + } + "local_variable_declaration" => { + if let Some(name) = java_variable_declarator_name(node, source)? { + let signature = java_variable_signature(node, source, &name)?; + return Ok(Some(JavaSymbolCandidate { + kind: "variable", + name, + extends_name: None, + signature, + })); + } + } + _ => {} + } + + Ok(None) +} + +fn find_java_package<'a>( + root: SyntaxNode<'a>, + source: &[u8], +) -> Result)>, Box> { + for child in root.named_children(&mut root.walk()) { + if child.kind() == "package_declaration" { + if let Some(name) = java_scoped_name(child, source)? { + return Ok(Some((name, child))); + } + } + } + Ok(None) +} + +fn extract_java_imports( + node: SyntaxNode, + source: &[u8], + relative_path: &str, + from_node_id: &str, + nodes: &mut Vec, + edges: &mut Vec, + unresolved_refs: &mut Vec, +) -> Result<(), Box> { + if node.kind() != "import_declaration" { + return Ok(()); + } + let Some(module_name) = java_scoped_name(node, source)? else { + return Ok(()); + }; + let import_node = ExtractedNode::symbol(relative_path, "import", &module_name, node, "java"); + let import_node_id = import_node.id.clone(); + edges.push(ExtractedEdge { + source: from_node_id.to_string(), + target: import_node_id, + kind: "contains".to_string(), + line: import_node.start_line, + col: import_node.start_column, + }); + nodes.push(import_node); + push_ref( + unresolved_refs, + from_node_id, + &module_name, + "imports", + node, + relative_path, + SourceLanguage::Java, + ); + Ok(()) +} + +fn extract_java_statement_refs( + node: SyntaxNode, + source: &[u8], + relative_path: &str, + from_node_id: &str, + unresolved_refs: &mut Vec, +) -> Result<(), Box> { + if node.kind() != "method_invocation" { + return Ok(()); + } + let Some(name_node) = node.child_by_field_name("name") else { + return Ok(()); + }; + let reference_name = java_method_invocation_reference_name(node, name_node, source)?; + push_ref( + unresolved_refs, + from_node_id, + &reference_name, + "calls", + name_node, + relative_path, + SourceLanguage::Java, + ); + Ok(()) +} + +fn java_method_invocation_reference_name( + node: SyntaxNode, + name_node: SyntaxNode, + source: &[u8], +) -> Result> { + let method_name = name_node.utf8_text(source)?; + let receiver = node + .child_by_field_name("object") + .or_else(|| node.child_by_field_name("receiver")); + let Some(receiver) = receiver else { + return Ok(method_name.to_string()); + }; + let mut receiver_text = receiver.utf8_text(source)?.trim().to_string(); + if let Some(stripped) = receiver_text.strip_prefix("this.") { + receiver_text = stripped.to_string(); + } + if receiver_text.is_empty() || receiver_text.contains('(') { + return Ok(method_name.to_string()); + } + Ok(format!("{receiver_text}.{method_name}")) +} + +fn java_scoped_name( + node: SyntaxNode, + source: &[u8], +) -> Result, Box> { + for child in node.named_children(&mut node.walk()) { + if matches!( + child.kind(), + "scoped_identifier" | "identifier" | "asterisk" | "static" + ) { + let text = child.utf8_text(source)?.trim(); + if !text.is_empty() && text != "static" { + return Ok(Some(text.to_string())); + } + } + } + Ok(None) +} + +fn java_variable_declarator_name( + node: SyntaxNode, + source: &[u8], +) -> Result, Box> { + for child in node.named_children(&mut node.walk()) { + if child.kind() == "variable_declarator" { + if let Some(name_node) = child.child_by_field_name("name") { + return Ok(Some(name_node.utf8_text(source)?.to_string())); + } + } + } + Ok(None) +} + +fn java_variable_signature( + node: SyntaxNode, + source: &[u8], + name: &str, +) -> Result, Box> { + let type_node = node.child_by_field_name("type").or_else(|| { + node.named_children(&mut node.walk()) + .find(|child| !matches!(child.kind(), "modifiers" | "variable_declarator")) + }); + let Some(type_node) = type_node else { + return Ok(None); + }; + let type_text = type_node.utf8_text(source)?.trim(); + if type_text.is_empty() { + return Ok(None); + } + Ok(Some(format!("{type_text} {name}"))) +} + +fn java_type_relation_refs( + node: SyntaxNode, + source: &[u8], +) -> Result, Box> { + if !matches!( + node.kind(), + "class_declaration" | "interface_declaration" | "enum_declaration" + ) { + return Ok(Vec::new()); + } + let header = node + .utf8_text(source)? + .split_once('{') + .map(|(header, _)| header) + .unwrap_or_else(|| node.utf8_text(source).unwrap_or("")); + let mut refs = Vec::new(); + if let Some(name) = java_type_name_after_keyword(header, "extends") { + refs.push(JavaTypeRelation { + kind: "extends", + name, + }); + } + if let Some(rest) = java_text_after_keyword(header, "implements") { + for name in rest.split(',').filter_map(java_clean_type_name) { + refs.push(JavaTypeRelation { + kind: "implements", + name, + }); + } + } + Ok(refs) +} + +fn java_type_name_after_keyword(header: &str, keyword: &str) -> Option { + java_text_after_keyword(header, keyword) + .and_then(|rest| rest.split(',').next().and_then(java_clean_type_name)) +} + +fn java_text_after_keyword<'a>(header: &'a str, keyword: &str) -> Option<&'a str> { + let marker = format!(" {keyword} "); + header.split_once(&marker).map(|(_, rest)| rest) +} + +fn java_clean_type_name(raw: &str) -> Option { + let first = raw + .split_whitespace() + .next() + .unwrap_or("") + .trim() + .trim_end_matches('{'); + let no_generics = first.split_once('<').map(|(left, _)| left).unwrap_or(first); + let leaf = no_generics.rsplit('.').next().unwrap_or(no_generics).trim(); + (!leaf.is_empty()).then(|| leaf.to_string()) +} + +fn java_anonymous_class_body(node: SyntaxNode) -> Option { + node.named_children(&mut node.walk()) + .find(|child| child.kind() == "class_body") +} + +fn java_object_creation_type_name( + node: SyntaxNode, + source: &[u8], +) -> Result, Box> { + let type_node = node + .child_by_field_name("constructor") + .or_else(|| node.child_by_field_name("type")) + .or_else(|| node.child_by_field_name("name")) + .or_else(|| node.named_child(0)); + let Some(type_node) = type_node else { + return Ok(None); + }; + let mut type_name = type_node.utf8_text(source)?.trim().to_string(); + if let Some(index) = type_name.find('<') { + type_name.truncate(index); + } + if let Some(index) = type_name.rfind('.') { + type_name = type_name[index + 1..].to_string(); + } + let type_name = type_name.trim().to_string(); + if type_name.is_empty() { + return Ok(None); + } + Ok(Some((type_name, (node.start_position().row + 1) as i64))) +} + +fn java_qualified_name(qualified_base: &str, name: &str, containers: &[String]) -> String { + let mut parts = Vec::with_capacity(containers.len() + 2); + parts.push(qualified_base.to_string()); + parts.extend(containers.iter().cloned()); + parts.push(name.to_string()); + parts.join("::") +} + fn extract_python_symbols( root: SyntaxNode, source: &[u8], @@ -11267,6 +11806,7 @@ struct ExtractedNode { file_path: String, language: String, visibility: Option, + signature: Option, start_line: i64, end_line: i64, start_column: i64, @@ -11285,6 +11825,7 @@ impl ExtractedNode { file_path: relative_path.to_string(), language: language.to_string(), visibility: None, + signature: None, start_line: 1, end_line, start_column: 0, @@ -11310,6 +11851,45 @@ impl ExtractedNode { node: SyntaxNode, language: &str, visibility: Option, + ) -> Self { + Self::symbol_with_visibility_and_qualified_name( + relative_path, + kind, + name, + node, + language, + visibility, + format!("{}::{}", relative_path, name), + ) + } + + fn symbol_with_qualified_name( + relative_path: &str, + kind: &str, + name: &str, + node: SyntaxNode, + language: &str, + qualified_name: String, + ) -> Self { + Self::symbol_with_visibility_and_qualified_name( + relative_path, + kind, + name, + node, + language, + None, + qualified_name, + ) + } + + fn symbol_with_visibility_and_qualified_name( + relative_path: &str, + kind: &str, + name: &str, + node: SyntaxNode, + language: &str, + visibility: Option, + qualified_name: String, ) -> Self { let start = node.start_position(); let end = node.end_position(); @@ -11317,10 +11897,11 @@ impl ExtractedNode { id: generate_node_id(relative_path, kind, name, (start.row + 1) as i64), kind: kind.to_string(), name: name.to_string(), - qualified_name: format!("{}::{}", relative_path, name), + qualified_name, file_path: relative_path.to_string(), language: language.to_string(), visibility, + signature: None, start_line: (start.row + 1) as i64, end_line: (end.row + 1) as i64, start_column: start.column as i64, @@ -11347,6 +11928,7 @@ impl ExtractedNode { file_path: relative_path.to_string(), language: language.to_string(), visibility: None, + signature: None, start_line, end_line, start_column, @@ -11587,9 +12169,9 @@ fn insert_nodes(conn: &Connection, nodes: &[ExtractedNode]) -> rusqlite::Result< ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, - NULL, NULL, ?11, + NULL, ?11, ?12, 0, 0, 0, 0, - NULL, NULL, ?12 + NULL, NULL, ?13 )", )?; @@ -11605,6 +12187,7 @@ fn insert_nodes(conn: &Connection, nodes: &[ExtractedNode]) -> rusqlite::Result< node.end_line, node.start_column, node.end_column, + node.signature, node.visibility, node.updated_at, ])?; diff --git a/docs/benchmarks/2026-07-03-rust-owned-java-spring-petclinic-validation.md b/docs/benchmarks/2026-07-03-rust-owned-java-spring-petclinic-validation.md new file mode 100644 index 00000000..a2925120 --- /dev/null +++ b/docs/benchmarks/2026-07-03-rust-owned-java-spring-petclinic-validation.md @@ -0,0 +1,43 @@ +# Rust-Owned Java Corpus Validation + +Date: 2026-07-03 + +## Corpus + +- Repository: `spring-projects/spring-petclinic` +- Checkout: `b3ee2c5` +- Local path during validation: `/private/tmp/codegraph-corpus/spring-petclinic-java` +- Rationale: small-to-medium real Java/Spring project with production and test + Java files plus non-Java resource files. + +## Command + +```bash +CODEGRAPH_ALLOW_UNSAFE_NODE=1 \ +CODEGRAPH_NO_DAEMON=1 \ +CODEGRAPH_NO_RELAUNCH=1 \ +ZCODEGRAPH_RUST_CORE_BINARY=/Users/bilibili/Documents/workspace/github/jununfly/ZCodeGraph/target/debug/zcodegraph-core \ +node /Users/bilibili/Documents/workspace/github/jununfly/ZCodeGraph/dist/bin/zcodegraph.js init \ + /private/tmp/codegraph-corpus/spring-petclinic-java \ + --engine rust-hybrid +``` + +## Result + +- Indexed files: 72 +- Nodes: 992 +- Edges: 1,656 +- Languages: `java`, `properties`, `xml`, `yaml` +- Rust-owned languages included `java`. +- `engineByLanguage.java` was `rust`. +- `engineByFileCount.rust` was 47. +- Fallback files: 25, all from non-Java resources: + - `yaml`: 8 + - `properties`: 14 + - `xml`: 3 + +## Decision + +The Java baseline migration gate passes for this corpus. Java source files are +owned by the Rust indexer; remaining fallback evidence belongs to resource-file +languages outside this PR's Java baseline extraction scope. diff --git a/docs/prds/2026-07-03-rust-owned-migration-roadmap.md b/docs/prds/2026-07-03-rust-owned-migration-roadmap.md index 9aa97066..0b3a764b 100644 --- a/docs/prds/2026-07-03-rust-owned-migration-roadmap.md +++ b/docs/prds/2026-07-03-rust-owned-migration-roadmap.md @@ -63,7 +63,7 @@ Each language migration must include: These languages currently have TypeScript-owned tree-sitter extractor configs and should be migrated language by language: -- [ ] Java: classes, interfaces, annotations, enums, imports, method calls, +- [x] Java: classes, interfaces, annotations, enums, imports, method calls, package declarations, Spring/Play boundary decision. - [ ] C: functions, structs, enums, typedefs, includes, calls, header classification boundary. @@ -203,6 +203,8 @@ Near-term clean candidates from the current map: - Python framework sufficiency check after Python baseline Rust-owned evidence. - Go/Gin route ownership check. -- Java or Kotlin baseline migration, because Spring resolver boundaries are - already explicit. +- Kotlin baseline migration, because Spring resolver boundaries are already + explicit. Java baseline migration is complete; Spring/Play framework semantics + remain TypeScript-shell owned, with corpus evidence recorded in + `docs/benchmarks/2026-07-03-rust-owned-java-spring-petclinic-validation.md`. - Swift baseline migration, if mobile bridge ownership becomes product priority. diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 543598b8..cea7e982 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -13,7 +13,6 @@ import { javascriptExtractor } from './javascript'; import { pythonExtractor } from './python'; import { goExtractor } from './go'; import { rustExtractor } from './rust'; -import { javaExtractor } from './java'; import { cExtractor, cppExtractor } from './c-cpp'; import { csharpExtractor } from './csharp'; import { phpExtractor } from './php'; @@ -35,7 +34,6 @@ export const EXTRACTORS: Partial> = { python: pythonExtractor, go: goExtractor, rust: rustExtractor, - java: javaExtractor, c: cExtractor, cpp: cppExtractor, csharp: csharpExtractor, diff --git a/src/extraction/languages/java.ts b/src/extraction/languages/java.ts deleted file mode 100644 index c5976446..00000000 --- a/src/extraction/languages/java.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { Node as SyntaxNode } from 'web-tree-sitter'; -import { getNodeText, getChildByField } from '../tree-sitter-helpers'; -import type { LanguageExtractor } from '../tree-sitter-types'; - -export const javaExtractor: LanguageExtractor = { - functionTypes: [], - classTypes: ['class_declaration'], - methodTypes: ['method_declaration', 'constructor_declaration'], - // `annotation_type_declaration` is `@interface Foo { … }` — an annotation - // definition. Without it, annotation types (`@SerializedName`, `@GetMapping`, - // JPA/Spring annotations) aren't nodes, so the `@Foo` usages that DO get - // extracted can't resolve and the annotation file shows zero dependents. - interfaceTypes: ['interface_declaration', 'annotation_type_declaration'], - structTypes: [], - enumTypes: ['enum_declaration'], - enumMemberTypes: ['enum_constant'], - typeAliasTypes: [], - importTypes: ['import_declaration'], - callTypes: ['method_invocation'], - variableTypes: ['local_variable_declaration'], - fieldTypes: ['field_declaration'], - nameField: 'name', - bodyField: 'body', - paramsField: 'parameters', - returnField: 'type', - getSignature: (node, source) => { - const params = getChildByField(node, 'parameters'); - const returnType = getChildByField(node, 'type'); - if (!params) return undefined; - const paramsText = getNodeText(params, source); - return returnType ? getNodeText(returnType, source) + ' ' + paramsText : paramsText; - }, - getVisibility: (node) => { - for (let i = 0; i < node.childCount; i++) { - const child = node.child(i); - if (child?.type === 'modifiers') { - const text = child.text; - if (text.includes('public')) return 'public'; - if (text.includes('private')) return 'private'; - if (text.includes('protected')) return 'protected'; - } - } - return undefined; - }, - isStatic: (node) => { - for (let i = 0; i < node.childCount; i++) { - const child = node.child(i); - if (child?.type === 'modifiers' && child.text.includes('static')) { - return true; - } - } - return false; - }, - extractImport: (node, source) => { - const importText = source.substring(node.startIndex, node.endIndex).trim(); - const scopedId = node.namedChildren.find((c: SyntaxNode) => c.type === 'scoped_identifier'); - if (scopedId) { - const moduleName = source.substring(scopedId.startIndex, scopedId.endIndex); - return { moduleName, signature: importText }; - } - return null; - }, - packageTypes: ['package_declaration'], - extractPackage: (node, source) => { - // package_declaration → scoped_identifier or identifier (single-segment) - const id = node.namedChildren.find( - (c: SyntaxNode) => c.type === 'scoped_identifier' || c.type === 'identifier' - ); - return id ? source.substring(id.startIndex, id.endIndex).trim() : null; - }, -}; diff --git a/src/indexing/rust-hybrid-contract.ts b/src/indexing/rust-hybrid-contract.ts index 60025cb9..12b1d160 100644 --- a/src/indexing/rust-hybrid-contract.ts +++ b/src/indexing/rust-hybrid-contract.ts @@ -3,7 +3,7 @@ import { isGeneratedFile } from '../extraction/generated-detection'; import { scanDirectory } from '../extraction'; export const RUST_HYBRID_PHASE = 'phase-6-rust-owned-per-file-gap-fallback'; -export const RUST_HYBRID_RUST_OWNED_LANGUAGES = ['javascript', 'jsx', 'typescript', 'tsx', 'go', 'python', 'rust'] as const; +export const RUST_HYBRID_RUST_OWNED_LANGUAGES = ['javascript', 'jsx', 'typescript', 'tsx', 'go', 'java', 'python', 'rust'] as const; export type RustHybridFallbackState = 'healthy' | 'degraded' | 'pending'; export type RustOwnedGapCode = | 'rust-owned-parse-gap'