From bef791a730853bc38d25fe3f95fcdc5eb55e82be Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Fri, 1 May 2026 19:51:45 +0200 Subject: [PATCH] feat(group): auto-discover Java/Kotlin workspace cross-project contracts Scan Maven pom.xml and Gradle build files for inter-project deps, then match Java/Kotlin import statements against known group-internal base packages. Supports Maven dependency blocks, Gradle coordinate and project() dependencies, static imports, and Kotlin files. --- .../extractors/java-workspace-extractor.ts | 264 ++++++++++++++++++ .../group/extractors/workspace-extractor.ts | 11 + .../group/java-workspace-extractor.test.ts | 261 +++++++++++++++++ 3 files changed, 536 insertions(+) create mode 100644 gitnexus/src/core/group/extractors/java-workspace-extractor.ts create mode 100644 gitnexus/test/unit/group/java-workspace-extractor.test.ts diff --git a/gitnexus/src/core/group/extractors/java-workspace-extractor.ts b/gitnexus/src/core/group/extractors/java-workspace-extractor.ts new file mode 100644 index 000000000..013f91b4a --- /dev/null +++ b/gitnexus/src/core/group/extractors/java-workspace-extractor.ts @@ -0,0 +1,264 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { CypherExecutor } from '../contract-extractor.js'; +import type { GroupManifestLink, ContractRole } from '../types.js'; + +interface JavaProjectMeta { + groupId: string; + artifactId: string; + basePackage: string; + groupPath: string; + repoPath: string; + deps: string[]; +} + +interface ImportedSymbol { + artifactKey: string; + symbolName: string; + filePath: string; +} + +async function parseJavaManifest( + repoPath: string, +): Promise<{ groupId: string; artifactId: string; deps: string[] } | null> { + const pomPath = path.join(repoPath, 'pom.xml'); + try { + const content = await fs.readFile(pomPath, 'utf-8'); + return parsePom(content); + } catch { + // fall through to Gradle + } + + for (const name of ['build.gradle.kts', 'build.gradle']) { + const gradlePath = path.join(repoPath, name); + try { + const content = await fs.readFile(gradlePath, 'utf-8'); + return parseGradle(content, repoPath); + } catch { + continue; + } + } + + return null; +} + +function parsePom( + content: string, +): { groupId: string; artifactId: string; deps: string[] } | null { + const projectGroupMatch = content.match( + /]*>[\s\S]*?([^<]+)<\/groupId>/, + ); + const projectArtifactMatch = content.match( + /]*>[\s\S]*?([^<]+)<\/artifactId>/, + ); + if (!projectGroupMatch || !projectArtifactMatch) return null; + + const groupId = projectGroupMatch[1].trim(); + const artifactId = projectArtifactMatch[1].trim(); + + const deps: string[] = []; + const depBlocks = content.matchAll( + /\s*([\s\S]*?)<\/dependency>/g, + ); + for (const block of depBlocks) { + const gMatch = block[1].match(/([^<]+)<\/groupId>/); + const aMatch = block[1].match(/([^<]+)<\/artifactId>/); + if (gMatch && aMatch) { + deps.push(`${gMatch[1].trim()}:${aMatch[1].trim()}`); + } + } + + return { groupId, artifactId, deps: [...new Set(deps)] }; +} + +function parseGradle( + content: string, + repoPath: string, +): { groupId: string; artifactId: string; deps: string[] } | null { + const groupMatch = content.match(/group\s*=\s*['"]([^'"]+)['"]/); + const dirName = path.basename(repoPath); + const groupId = groupMatch ? groupMatch[1] : ''; + if (!groupId) return null; + + const artifactId = dirName; + + const deps: string[] = []; + // implementation("group:artifact:version") or api("group:artifact:version") + const depMatches = content.matchAll( + /(?:implementation|api|compileOnly|runtimeOnly)\s*\(\s*['"]([^'"]+)['"]\s*\)/g, + ); + for (const m of depMatches) { + const parts = m[1].split(':'); + if (parts.length >= 2) { + deps.push(`${parts[0]}:${parts[1]}`); + } + } + + // implementation(project(":subproject")) + const projDeps = content.matchAll( + /(?:implementation|api)\s*\(\s*project\s*\(\s*['"]([^'"]+)['"]\s*\)\s*\)/g, + ); + for (const m of projDeps) { + const subName = m[1].replace(/^:/, ''); + deps.push(`${groupId}:${subName}`); + } + + return { groupId, artifactId, deps: [...new Set(deps)] }; +} + +function deriveBasePackage(groupId: string, artifactId: string): string { + const sanitized = artifactId.replace(/-/g, '.'); + if (groupId.endsWith(`.${sanitized}`) || groupId === sanitized) { + return groupId; + } + return `${groupId}.${sanitized}`; +} + +async function scanJavaImports( + repoPath: string, + knownPackages: Map, +): Promise { + const results: ImportedSymbol[] = []; + const sourceFiles = await findJavaFiles(repoPath); + + for (const relFile of sourceFiles) { + const absPath = path.join(repoPath, relFile); + let content: string; + try { + content = await fs.readFile(absPath, 'utf-8'); + } catch { + continue; + } + + const importRegex = /^import\s+(?:static\s+)?([a-zA-Z][\w.]*\.[A-Z]\w*)/gm; + let match; + while ((match = importRegex.exec(content)) !== null) { + const fullImport = match[1]; + for (const [basePkg, artifactKey] of knownPackages) { + if (fullImport.startsWith(basePkg + '.') || fullImport === basePkg) { + const parts = fullImport.split('.'); + const className = parts[parts.length - 1]; + if (isPascalCase(className)) { + results.push({ + artifactKey, + symbolName: className, + filePath: relFile, + }); + } + break; + } + } + } + } + + return results; +} + +function isPascalCase(name: string): boolean { + return /^[A-Z][A-Za-z0-9]*$/.test(name); +} + +async function findJavaFiles(repoPath: string): Promise { + const results: string[] = []; + const IGNORE = new Set([ + 'node_modules', + '.git', + '.gitnexus', + 'build', + 'target', + '.gradle', + '.idea', + 'bin', + ]); + + async function walk(dir: string, rel: string): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (IGNORE.has(entry.name)) continue; + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + await walk(path.join(dir, entry.name), childRel); + } else if (entry.name.endsWith('.java') || entry.name.endsWith('.kt')) { + results.push(childRel); + } + } + } + + await walk(repoPath, ''); + return results; +} + +export interface JavaWorkspaceResult { + links: GroupManifestLink[]; + discoveredProjects: Map; +} + +export async function extractJavaWorkspaceLinks( + repos: Record, + repoPaths: Map, + _dbExecutors?: Map, +): Promise { + const projectsByKey = new Map(); + const projectsByGroupPath = new Map(); + + for (const [groupPath] of Object.entries(repos)) { + const repoPath = repoPaths.get(groupPath); + if (!repoPath) continue; + + const manifest = await parseJavaManifest(repoPath); + if (!manifest) continue; + + const key = `${manifest.groupId}:${manifest.artifactId}`; + const meta: JavaProjectMeta = { + groupId: manifest.groupId, + artifactId: manifest.artifactId, + basePackage: deriveBasePackage(manifest.groupId, manifest.artifactId), + groupPath, + repoPath, + deps: manifest.deps, + }; + projectsByKey.set(key, meta); + projectsByGroupPath.set(groupPath, meta); + } + + const links: GroupManifestLink[] = []; + const seen = new Set(); + + for (const [, proj] of projectsByGroupPath) { + const groupDeps = proj.deps.filter((d) => projectsByKey.has(d)); + if (groupDeps.length === 0) continue; + + const knownPackages = new Map(); + for (const dep of groupDeps) { + const depMeta = projectsByKey.get(dep); + if (depMeta) knownPackages.set(depMeta.basePackage, dep); + } + + const imports = await scanJavaImports(proj.repoPath, knownPackages); + + for (const imp of imports) { + const providerProj = projectsByKey.get(imp.artifactKey); + if (!providerProj) continue; + + const key = `${proj.groupPath}→${providerProj.groupPath}::${imp.symbolName}`; + if (seen.has(key)) continue; + seen.add(key); + + const link: GroupManifestLink = { + from: providerProj.groupPath, + to: proj.groupPath, + type: 'custom', + contract: imp.symbolName, + role: 'provider' as ContractRole, + }; + links.push(link); + } + } + + return { links, discoveredProjects: projectsByGroupPath }; +} diff --git a/gitnexus/src/core/group/extractors/workspace-extractor.ts b/gitnexus/src/core/group/extractors/workspace-extractor.ts index fcf1f3448..7e452e3f1 100644 --- a/gitnexus/src/core/group/extractors/workspace-extractor.ts +++ b/gitnexus/src/core/group/extractors/workspace-extractor.ts @@ -4,6 +4,7 @@ import { extractRustWorkspaceLinks } from './rust-workspace-extractor.js'; import { extractNodeWorkspaceLinks } from './node-workspace-extractor.js'; import { extractPythonWorkspaceLinks } from './python-workspace-extractor.js'; import { extractGoWorkspaceLinks } from './go-workspace-extractor.js'; +import { extractJavaWorkspaceLinks } from './java-workspace-extractor.js'; export interface WorkspaceDiscoveryResult { links: GroupManifestLink[]; @@ -64,5 +65,15 @@ export async function discoverWorkspaceLinks( }); } + const javaResult = await extractJavaWorkspaceLinks(repos, repoPaths, dbExecutors); + if (javaResult.links.length > 0) { + links.push(...javaResult.links); + stats.push({ + ecosystem: 'Java', + linkCount: javaResult.links.length, + projectCount: javaResult.discoveredProjects.size, + }); + } + return { links, stats }; } diff --git a/gitnexus/test/unit/group/java-workspace-extractor.test.ts b/gitnexus/test/unit/group/java-workspace-extractor.test.ts new file mode 100644 index 000000000..4f6a04df6 --- /dev/null +++ b/gitnexus/test/unit/group/java-workspace-extractor.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { extractJavaWorkspaceLinks } from '../../../src/core/group/extractors/java-workspace-extractor.js'; + +describe('JavaWorkspaceExtractor', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-java-ws-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + async function writeFile(relPath: string, content: string) { + const absPath = path.join(tmpDir, relPath); + await fs.mkdir(path.dirname(absPath), { recursive: true }); + await fs.writeFile(absPath, content, 'utf-8'); + } + + const pomTemplate = (g: string, a: string, deps: string[] = []) => { + const depXml = deps + .map((d) => { + const [gid, aid] = d.split(':'); + return `${gid}${aid}`; + }) + .join('\n'); + return `${g}${a}${depXml}`; + }; + + it('discovers cross-project imports via Maven pom.xml', async () => { + await writeFile('models/pom.xml', pomTemplate('com.acme', 'models')); + await writeFile( + 'models/src/main/java/com/acme/models/User.java', + 'package com.acme.models;\npublic class User {}\n', + ); + + await writeFile( + 'api/pom.xml', + pomTemplate('com.acme', 'api', ['com.acme:models']), + ); + await writeFile( + 'api/src/main/java/com/acme/api/UserService.java', + 'package com.acme.api;\nimport com.acme.models.User;\npublic class UserService {}\n', + ); + + const repos = { models: 'models', api: 'api' }; + const repoPaths = new Map([ + ['models', path.join(tmpDir, 'models')], + ['api', path.join(tmpDir, 'api')], + ]); + + const result = await extractJavaWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0]).toEqual({ + from: 'models', + to: 'api', + type: 'custom', + contract: 'User', + role: 'provider', + }); + }); + + it('handles Gradle build files', async () => { + await writeFile( + 'core/build.gradle.kts', + "group = \"com.acme\"\nversion = \"1.0\"\n", + ); + await writeFile( + 'core/src/main/java/com/acme/core/Config.java', + 'package com.acme.core;\npublic class Config {}\n', + ); + + await writeFile( + 'svc/build.gradle.kts', + "group = \"com.acme\"\nversion = \"1.0\"\ndependencies {\n implementation(\"com.acme:core:1.0\")\n}\n", + ); + await writeFile( + 'svc/src/main/java/com/acme/svc/App.java', + 'package com.acme.svc;\nimport com.acme.core.Config;\npublic class App {}\n', + ); + + const repos = { core: 'core', svc: 'svc' }; + const repoPaths = new Map([ + ['core', path.join(tmpDir, 'core')], + ['svc', path.join(tmpDir, 'svc')], + ]); + + const result = await extractJavaWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0].contract).toBe('Config'); + }); + + it('handles Gradle project dependencies', async () => { + await writeFile( + 'common/build.gradle', + "group = 'com.org'\nversion = '1.0'\n", + ); + await writeFile( + 'common/src/main/java/com/org/common/Entity.java', + 'package com.org.common;\npublic class Entity {}\n', + ); + + await writeFile( + 'app/build.gradle', + "group = 'com.org'\nversion = '1.0'\ndependencies {\n implementation(project(':common'))\n}\n", + ); + await writeFile( + 'app/src/main/java/com/org/app/Main.java', + 'package com.org.app;\nimport com.org.common.Entity;\npublic class Main {}\n', + ); + + const repos = { common: 'common', app: 'app' }; + const repoPaths = new Map([ + ['common', path.join(tmpDir, 'common')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractJavaWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0].contract).toBe('Entity'); + }); + + it('handles static imports', async () => { + await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib')); + await writeFile( + 'lib/src/main/java/com/acme/lib/Constants.java', + 'package com.acme.lib;\npublic class Constants {}\n', + ); + + await writeFile( + 'app/pom.xml', + pomTemplate('com.acme', 'app', ['com.acme:lib']), + ); + await writeFile( + 'app/src/main/java/com/acme/app/Main.java', + 'package com.acme.app;\nimport static com.acme.lib.Constants;\npublic class Main {}\n', + ); + + const repos = { lib: 'lib', app: 'app' }; + const repoPaths = new Map([ + ['lib', path.join(tmpDir, 'lib')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractJavaWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0].contract).toBe('Constants'); + }); + + it('skips repos without Java manifest', async () => { + await writeFile('rs-app/Cargo.toml', '[package]\nname = "rapp"\n'); + + const repos = { app: 'rapp' }; + const repoPaths = new Map([['app', path.join(tmpDir, 'rs-app')]]); + + const result = await extractJavaWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(0); + expect(result.discoveredProjects.size).toBe(0); + }); + + it('deduplicates identical imports from multiple files', async () => { + await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib')); + await writeFile( + 'lib/src/main/java/com/acme/lib/Token.java', + 'package com.acme.lib;\npublic class Token {}\n', + ); + + await writeFile( + 'app/pom.xml', + pomTemplate('com.acme', 'app', ['com.acme:lib']), + ); + await writeFile( + 'app/src/main/java/com/acme/app/A.java', + 'package com.acme.app;\nimport com.acme.lib.Token;\npublic class A {}\n', + ); + await writeFile( + 'app/src/main/java/com/acme/app/B.java', + 'package com.acme.app;\nimport com.acme.lib.Token;\npublic class B {}\n', + ); + + const repos = { lib: 'lib', app: 'app' }; + const repoPaths = new Map([ + ['lib', path.join(tmpDir, 'lib')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractJavaWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + }); + + it('discovers Kotlin file imports from Java projects', async () => { + await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib')); + await writeFile( + 'lib/src/main/kotlin/com/acme/lib/Model.kt', + 'package com.acme.lib\ndata class Model(val id: Int)\n', + ); + + await writeFile( + 'app/pom.xml', + pomTemplate('com.acme', 'app', ['com.acme:lib']), + ); + await writeFile( + 'app/src/main/kotlin/com/acme/app/Main.kt', + 'package com.acme.app\nimport com.acme.lib.Model\nfun main() {}\n', + ); + + const repos = { lib: 'lib', app: 'app' }; + const repoPaths = new Map([ + ['lib', path.join(tmpDir, 'lib')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractJavaWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0].contract).toBe('Model'); + }); + + it('discovers multiple types from the same dependency', async () => { + await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib')); + await writeFile( + 'lib/src/main/java/com/acme/lib/Request.java', + 'package com.acme.lib;\npublic class Request {}\n', + ); + await writeFile( + 'lib/src/main/java/com/acme/lib/Response.java', + 'package com.acme.lib;\npublic class Response {}\n', + ); + + await writeFile( + 'app/pom.xml', + pomTemplate('com.acme', 'app', ['com.acme:lib']), + ); + await writeFile( + 'app/src/main/java/com/acme/app/Handler.java', + 'package com.acme.app;\nimport com.acme.lib.Request;\nimport com.acme.lib.Response;\npublic class Handler {}\n', + ); + + const repos = { lib: 'lib', app: 'app' }; + const repoPaths = new Map([ + ['lib', path.join(tmpDir, 'lib')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractJavaWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(2); + const contracts = result.links.map((l) => l.contract).sort(); + expect(contracts).toEqual(['Request', 'Response']); + }); +});