mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
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.
This commit is contained in:
parent
3a7c0fb566
commit
bef791a730
3 changed files with 536 additions and 0 deletions
264
gitnexus/src/core/group/extractors/java-workspace-extractor.ts
Normal file
264
gitnexus/src/core/group/extractors/java-workspace-extractor.ts
Normal file
|
|
@ -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(
|
||||
/<project[^>]*>[\s\S]*?<groupId>([^<]+)<\/groupId>/,
|
||||
);
|
||||
const projectArtifactMatch = content.match(
|
||||
/<project[^>]*>[\s\S]*?<artifactId>([^<]+)<\/artifactId>/,
|
||||
);
|
||||
if (!projectGroupMatch || !projectArtifactMatch) return null;
|
||||
|
||||
const groupId = projectGroupMatch[1].trim();
|
||||
const artifactId = projectArtifactMatch[1].trim();
|
||||
|
||||
const deps: string[] = [];
|
||||
const depBlocks = content.matchAll(
|
||||
/<dependency>\s*([\s\S]*?)<\/dependency>/g,
|
||||
);
|
||||
for (const block of depBlocks) {
|
||||
const gMatch = block[1].match(/<groupId>([^<]+)<\/groupId>/);
|
||||
const aMatch = block[1].match(/<artifactId>([^<]+)<\/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<string, string>,
|
||||
): Promise<ImportedSymbol[]> {
|
||||
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<string[]> {
|
||||
const results: string[] = [];
|
||||
const IGNORE = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.gitnexus',
|
||||
'build',
|
||||
'target',
|
||||
'.gradle',
|
||||
'.idea',
|
||||
'bin',
|
||||
]);
|
||||
|
||||
async function walk(dir: string, rel: string): Promise<void> {
|
||||
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<string, JavaProjectMeta>;
|
||||
}
|
||||
|
||||
export async function extractJavaWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
_dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<JavaWorkspaceResult> {
|
||||
const projectsByKey = new Map<string, JavaProjectMeta>();
|
||||
const projectsByGroupPath = new Map<string, JavaProjectMeta>();
|
||||
|
||||
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<string>();
|
||||
|
||||
for (const [, proj] of projectsByGroupPath) {
|
||||
const groupDeps = proj.deps.filter((d) => projectsByKey.has(d));
|
||||
if (groupDeps.length === 0) continue;
|
||||
|
||||
const knownPackages = new Map<string, string>();
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
261
gitnexus/test/unit/group/java-workspace-extractor.test.ts
Normal file
261
gitnexus/test/unit/group/java-workspace-extractor.test.ts
Normal file
|
|
@ -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 `<dependency><groupId>${gid}</groupId><artifactId>${aid}</artifactId></dependency>`;
|
||||
})
|
||||
.join('\n');
|
||||
return `<project><groupId>${g}</groupId><artifactId>${a}</artifactId><dependencies>${depXml}</dependencies></project>`;
|
||||
};
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue