fix(group): parse Maven child coordinates independently of parent POMs (#3108)

* fix(group): parse Maven child coordinates independently of parent POMs

Stop treating inherited parent groupId/artifactId as the child's identity so sibling repos no longer collide and workspace manifest links can resolve.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(group): parse Maven POMs with fast-xml-parser

Replace the hand-rolled tokenizer so child identity and CDATA/namespaces stay accurate, and collect only project.dependencies so BOM, profile, and plugin entries cannot create workspace links.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(group): parse Gradle identity, catalogs, and named coordinates

Read gradle.properties, settings.gradle, and the default libs.versions.toml catalog so workspace links work without executing Gradle, matching the static POM contract.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(group): resolve Kotlin Gradle DSL workspace coordinates

Honor Kotlin named arguments, catalog get()/asProvider(), type-safe projects.* accessors, and ksp/kapt/commonMain configs without executing Gradle.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3108)

Recognize Gradle group inside allprojects { } and Groovy name-first map coordinates so workspace identity and deps match common DSL forms.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3108)

Restore XMLParser.parse for POMs after /autofix swapped in tree-sitter parseSourceSafe, and match underscore catalog aliases from Gradle files.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Gergő Magyar 2026-08-31 16:49:43 +01:00 • committed by GitHub
parent 19f6731c34
commit e04e1ecc65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1099 additions and 37 deletions

View file

@ -20,6 +20,7 @@
"cors": "^2.8.5",
"express": "^5.2.1",
"express-rate-limit": "^8.4.1",
"fast-xml-parser": "^5.11.1",
"glob": "^13.0.6",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
@ -1397,6 +1398,18 @@
}
}
},
"node_modules/@nodable/entities": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@oxc-project/types": {
"version": "0.144.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
@ -2153,6 +2166,18 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/apache-arrow": {
"version": "21.1.0",
"resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz",
@ -3021,6 +3046,45 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-builder": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.6.2",
"xml-naming": "^0.3.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.11.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz",
"integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^3.0.0",
"fast-xml-builder": "^1.2.0",
"is-unsafe": "^2.0.0",
"path-expression-matcher": "^1.6.2",
"strnum": "^2.4.2",
"xml-naming": "^0.3.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -3481,6 +3545,18 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/is-unsafe": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/isexe": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
@ -4334,6 +4410,21 @@
"node": ">= 0.8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -5080,6 +5171,21 @@
"node": ">=0.10.0"
}
},
"node_modules/strnum": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz",
"integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"anynum": "^1.0.1"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@ -5765,6 +5871,21 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View file

@ -66,6 +66,7 @@
"cors": "^2.8.5",
"express": "^5.2.1",
"express-rate-limit": "^8.4.1",
"fast-xml-parser": "^5.11.1",
"glob": "^13.0.6",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",

View file

@ -1,5 +1,6 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { XMLParser } from 'fast-xml-parser';
import type { CypherExecutor } from '../contract-extractor.js';
import type { GroupManifestLink, ContractRole } from '../types.js';
import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
@ -20,6 +21,21 @@ interface ImportedSymbol {
filePath: string;
}
type XmlNode = Record<string, unknown>;
// POMs are static metadata. Parse hierarchy with a real XML parser, but do not
// invoke Maven or resolve the effective model. Properties, profiles, and remote
// parent resolution remain outside this extractor's deterministic boundary.
const pomParser = new XMLParser({
ignoreAttributes: true,
removeNSPrefix: true,
trimValues: true,
parseTagValue: false,
processEntities: false,
ignoreDeclaration: true,
ignorePiTags: true,
});
async function parseJavaManifest(
repoPath: string,
): Promise<{ groupId: string; artifactId: string; deps: string[] } | null> {
@ -28,14 +44,15 @@ async function parseJavaManifest(
const content = await fs.readFile(pomPath, 'utf-8');
return parsePom(content);
} catch {
// fall through to Gradle
// Missing pom.xml — fall through to Gradle.
}
const gradleSidecars = await readGradleSidecars(repoPath);
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);
return parseGradle(content, repoPath, gradleSidecars);
} catch {
continue;
}
@ -44,59 +61,286 @@ async function parseJavaManifest(
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;
interface GradleSidecars {
propertiesGroup?: string;
rootProjectName?: string;
catalogLibraries: Map<string, string>;
catalogBundles: Map<string, string[]>;
}
const groupId = projectGroupMatch[1].trim();
const artifactId = projectArtifactMatch[1].trim();
async function readIfPresent(filePath: string): Promise<string | undefined> {
try {
return await fs.readFile(filePath, 'utf-8');
} catch {
return undefined;
}
}
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()}`);
async function readGradleSidecars(repoPath: string): Promise<GradleSidecars> {
const [properties, settingsKts, settingsGroovy, catalog] = await Promise.all([
readIfPresent(path.join(repoPath, 'gradle.properties')),
readIfPresent(path.join(repoPath, 'settings.gradle.kts')),
readIfPresent(path.join(repoPath, 'settings.gradle')),
readIfPresent(path.join(repoPath, 'gradle', 'libs.versions.toml')),
]);
const sidecars: GradleSidecars = {
catalogLibraries: new Map(),
catalogBundles: new Map(),
};
const groupMatch = properties?.match(/(?:^|\n)\s*group\s*=\s*([^\s#]+)/);
if (groupMatch) sidecars.propertiesGroup = groupMatch[1];
const settings = settingsKts ?? settingsGroovy;
const nameMatch = settings?.match(/rootProject\.name\s*=\s*['"]([^'"]+)['"]/);
if (nameMatch) sidecars.rootProjectName = nameMatch[1];
if (catalog) {
const parsed = parseGradleVersionCatalog(catalog);
sidecars.catalogLibraries = parsed.libraries;
sidecars.catalogBundles = parsed.bundles;
}
return sidecars;
}
function catalogAccessors(alias: string): string[] {
const dotted = alias.replace(/[-_]/g, '.');
const camel = alias.replace(/[-_]+([A-Za-z0-9])/g, (_, char: string) => char.toUpperCase());
return [...new Set([alias, dotted, camel])];
}
function projectAccessorToArtifactId(accessor: string): string {
const last = accessor.split('.').pop()!;
return last.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`).replace(/^-/, '');
}
function moduleToGa(module: string): string | undefined {
const parts = module.split(':');
return parts.length >= 2 ? `${parts[0]}:${parts[1]}` : undefined;
}
function parseInlineTomlTable(rhs: string): Record<string, string> {
const fields: Record<string, string> = {};
for (const match of rhs.matchAll(/([A-Za-z0-9_-]+)\s*=\s*['"]([^'"]+)['"]/g)) {
fields[match[1]] = match[2];
}
return fields;
}
/** Default Gradle catalog (`gradle/libs.versions.toml`) — aliases only, no version resolution. */
function parseGradleVersionCatalog(toml: string): {
libraries: Map<string, string>;
bundles: Map<string, string[]>;
} {
const libraries = new Map<string, string>();
const bundles = new Map<string, string[]>();
let section: 'libraries' | 'bundles' | 'other' = 'other';
const addLibrary = (alias: string, ga: string) => {
for (const accessor of catalogAccessors(alias)) libraries.set(accessor, ga);
};
for (const raw of toml.split(/\r?\n/)) {
const line = raw.replace(/#.*$/, '').trim();
if (!line) continue;
const header = line.match(/^\[([^\]]+)\]$/);
if (header) {
const name = header[1];
section =
name === 'libraries' || name.endsWith('.libraries')
? 'libraries'
: name === 'bundles' || name.endsWith('.bundles')
? 'bundles'
: 'other';
continue;
}
if (section === 'libraries') {
const dottedModule = line.match(/^([A-Za-z0-9._-]+)\.module\s*=\s*['"]([^'"]+)['"]$/);
if (dottedModule) {
const ga = moduleToGa(dottedModule[2]);
if (ga) addLibrary(dottedModule[1], ga);
continue;
}
const assignment = line.match(/^([A-Za-z0-9._-]+)\s*=\s*(.+)$/);
if (!assignment) continue;
const alias = assignment[1];
const rhs = assignment[2].trim();
const quoted = rhs.match(/^['"]([^'"]+)['"]$/);
if (quoted) {
const ga = moduleToGa(quoted[1]);
if (ga) addLibrary(alias, ga);
continue;
}
const table = parseInlineTomlTable(rhs);
const ga = table.module
? moduleToGa(table.module)
: table.group && table.name
? `${table.group}:${table.name}`
: undefined;
if (ga) addLibrary(alias, ga);
continue;
}
if (section === 'bundles') {
const assignment = line.match(/^([A-Za-z0-9._-]+)\s*=\s*\[([^\]]*)\]$/);
if (!assignment) continue;
const members = [...assignment[2].matchAll(/['"]([^'"]+)['"]/g)].map((match) => match[1]);
for (const accessor of catalogAccessors(assignment[1])) bundles.set(accessor, members);
}
}
return { libraries, bundles };
}
const GRADLE_GROUP_PATTERNS = [
/(?:^|[\n{;])\s*(?:rootProject\.)?group\s*=\s*['"]([^'"]+)['"]/,
/(?:^|[\n{;])\s*group\s+['"]([^'"]+)['"]/,
];
const GRADLE_COORD_CONFIGS =
'implementation|api|compileOnly|runtimeOnly|testImplementation|testApi|testCompileOnly|compile|kapt|ksp|commonMainImplementation|commonMainApi';
const CATALOG_ALIAS = '([A-Za-z0-9_]+(?:\\.[A-Za-z0-9_]+)*)(?:\\.get\\(\\)|\\.asProvider\\(\\))?';
function gradleDepRe(suffix: string): RegExp {
return new RegExp(`(?:${GRADLE_COORD_CONFIGS})\\s*${suffix}`, 'g');
}
function parseGradleGroup(content: string): string | undefined {
for (const pattern of GRADLE_GROUP_PATTERNS) {
const match = content.match(pattern);
if (match?.[1]) return match[1];
}
return undefined;
}
function asXmlNode(value: unknown): XmlNode | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as XmlNode)
: undefined;
}
function xmlText(value: unknown): string | undefined {
if (typeof value === 'string' || typeof value === 'number') {
const text = String(value).trim();
return text || undefined;
}
const nested = asXmlNode(value)?.['#text'];
if (nested === undefined) return undefined;
return xmlText(nested);
}
function xmlChildText(node: XmlNode | undefined, name: string): string | undefined {
return node ? xmlText(node[name]) : undefined;
}
function asList(value: unknown): unknown[] {
if (value === undefined || value === null) return [];
return Array.isArray(value) ? value : [value];
}
/** Direct project dependencies only — not BOM, profiles, or plugin classpath. */
function collectProjectDependencies(project: XmlNode, deps: string[]): void {
const dependencies = asXmlNode(project.dependencies);
if (!dependencies) return;
for (const dep of asList(dependencies.dependency)) {
const depNode = asXmlNode(dep);
const groupId = xmlChildText(depNode, 'groupId');
const artifactId = xmlChildText(depNode, 'artifactId');
if (groupId && artifactId) deps.push(`${groupId}:${artifactId}`);
}
}
function parsePom(content: string): { groupId: string; artifactId: string; deps: string[] } | null {
let parsed: unknown;
try {
// parseSourceSafe guards tree-sitter's Windows SIGSEGV by switching to a
// chunked input callback above 16 KB; XMLParser only accepts XML text, so
// routing POMs through it silently yields an empty document.
// eslint-disable-next-line gitnexus/require-safe-parse
parsed = pomParser.parse(content);
} catch {
return null;
}
const project = asXmlNode(asXmlNode(parsed)?.project);
if (!project) return null;
// Maven inherits groupId from <parent>, but artifactId is always the
// project's own direct child and must never fall back to parent.artifactId.
const groupId =
xmlChildText(project, 'groupId') ?? xmlChildText(asXmlNode(project.parent), 'groupId');
const artifactId = xmlChildText(project, 'artifactId');
if (!groupId || !artifactId) return null;
const deps: string[] = [];
collectProjectDependencies(project, deps);
return { groupId, artifactId, deps: [...new Set(deps)] };
}
function parseGradle(
content: string,
repoPath: string,
sidecars: GradleSidecars = { catalogLibraries: new Map(), catalogBundles: new Map() },
): { groupId: string; artifactId: string; deps: string[] } | null {
const groupMatch = content.match(/group\s*=\s*['"]([^'"]+)['"]/);
const dirName = path.basename(repoPath);
const groupId = groupMatch ? groupMatch[1] : '';
// Static text + default catalog file. Do not execute Gradle.
const groupId = parseGradleGroup(content) ?? sidecars.propertiesGroup ?? '';
if (!groupId) return null;
const artifactId = dirName;
const artifactId = sidecars.rootProjectName ?? path.basename(repoPath);
const { catalogLibraries, catalogBundles } = sidecars;
const deps: string[] = [];
// implementation("group:artifact:version") or api("group:artifact:version")
const depMatches = content.matchAll(
/(?:implementation|api|compileOnly|runtimeOnly)\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
const pushCatalogAlias = (alias: string) => {
const ga = catalogLibraries.get(alias);
if (ga) deps.push(ga);
};
const namedPattern = gradleDepRe(
`(?:\\(\\s*)?(?:group\\s*=\\s*['"](?<group1>[^'"]+)['"]\\s*,\\s*name\\s*=\\s*['"](?<name1>[^'"]+)['"]|name\\s*=\\s*['"](?<name2>[^'"]+)['"]\\s*,\\s*group\\s*=\\s*['"](?<group2>[^'"]+)['"]|group:\\s*['"](?<group3>[^'"]+)['"]\\s*,\\s*name:\\s*['"](?<name3>[^'"]+)['"]|name:\\s*['"](?<name4>[^'"]+)['"]\\s*,\\s*group:\\s*['"](?<group4>[^'"]+)['"])`,
);
for (const m of depMatches) {
const parts = m[1].split(':');
if (parts.length >= 2) {
deps.push(`${parts[0]}:${parts[1]}`);
for (const match of content.matchAll(namedPattern)) {
const group =
match.groups?.group1 ?? match.groups?.group2 ?? match.groups?.group3 ?? match.groups?.group4;
const name =
match.groups?.name1 ?? match.groups?.name2 ?? match.groups?.name3 ?? match.groups?.name4;
if (group && name) deps.push(`${group}:${name}`);
}
for (const match of content.matchAll(
gradleDepRe(`(?:\\(\\s*)?libs(?:\\.libraries)?\\.(?!bundles\\.|plugins\\.)${CATALOG_ALIAS}`),
)) {
pushCatalogAlias(match[1]);
}
for (const match of content.matchAll(
gradleDepRe(`(?:\\(\\s*)?libs\\.bundles\\.${CATALOG_ALIAS}`),
)) {
for (const member of catalogBundles.get(match[1]) ?? []) {
for (const accessor of catalogAccessors(member)) pushCatalogAlias(accessor);
}
}
// 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}`);
for (const match of content.matchAll(gradleDepRe(`\\(\\s*projects\\.([A-Za-z][A-Za-z0-9.]*)`))) {
deps.push(`${groupId}:${projectAccessorToArtifactId(match[1])}`);
}
for (const match of content.matchAll(
gradleDepRe(`(?:\\(\\s*['"]([^'"]+)['"]\\s*\\)|['"]([^'"]+)['"])`),
)) {
const coord = match[1] ?? match[2];
if (!coord) continue;
const parts = coord.split(':');
if (parts.length >= 2) deps.push(`${parts[0]}:${parts[1]}`);
}
for (const match of content.matchAll(
gradleDepRe(`(?:\\(\\s*)?project\\s*\\(\\s*['"]([^'"]+)['"]\\s*\\)`),
)) {
deps.push(`${groupId}:${match[1].replace(/^:/, '')}`);
}
return { groupId, artifactId, deps: [...new Set(deps)] };

View file

@ -31,6 +31,24 @@ describe('JavaWorkspaceExtractor', () => {
return `<project><groupId>${g}</groupId><artifactId>${a}</artifactId><dependencies>${depXml}</dependencies></project>`;
};
const inheritedPomTemplate = (artifactId: 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 xmlns="http://maven.apache.org/POM/4.0.0">
<parent>
<groupId>com.example</groupId>
<artifactId>parent</artifactId>
<version>1</version>
</parent>
<artifactId>${artifactId}</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(
@ -62,6 +80,280 @@ describe('JavaWorkspaceExtractor', () => {
});
});
it('keeps child artifacts distinct when independent repositories share a Maven parent', async () => {
await writeFile('parent/pom.xml', pomTemplate('com.example', 'parent'));
await writeFile('shared-lib/pom.xml', inheritedPomTemplate('shared-lib'));
await writeFile(
'shared-lib/src/main/java/com/example/shared/lib/SharedType.java',
'package com.example.shared.lib;\npublic class SharedType {}\n',
);
await writeFile(
'service-a/pom.xml',
inheritedPomTemplate('service-a', ['com.example:shared-lib']),
);
await writeFile(
'service-a/src/main/java/com/example/service/a/App.java',
'package com.example.service.a;\nimport com.example.shared.lib.SharedType;\npublic class App {}\n',
);
await writeFile(
'service-b/pom.xml',
inheritedPomTemplate('service-b', ['com.example:shared-lib']),
);
await writeFile(
'service-b/src/main/kotlin/com/example/service/b/App.kt',
'package com.example.service.b\nimport com.example.shared.lib.SharedType\nclass App\n',
);
const repos = {
parent: 'parent',
'shared-lib': 'shared-lib',
'service-a': 'service-a',
'service-b': 'service-b',
};
const repoPaths = new Map(
Object.keys(repos).map((groupPath) => [groupPath, path.join(tmpDir, groupPath)]),
);
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
expect(result.discoveredProjects.size).toBe(4);
expect(result.discoveredProjects.get('parent')?.artifactId).toBe('parent');
expect(result.discoveredProjects.get('shared-lib')?.artifactId).toBe('shared-lib');
expect(result.discoveredProjects.get('service-a')?.artifactId).toBe('service-a');
expect(result.discoveredProjects.get('service-b')?.artifactId).toBe('service-b');
expect(result.links).toEqual([
{
from: 'shared-lib',
to: 'service-a',
type: 'custom',
contract: 'shared-lib::SharedType',
role: 'provider',
},
{
from: 'shared-lib',
to: 'service-b',
type: 'custom',
contract: 'shared-lib::SharedType',
role: 'provider',
},
]);
});
async function extractNamed(names: string[]) {
return extractJavaWorkspaceLinks(
Object.fromEntries(names.map((name) => [name, name])),
new Map(names.map((name) => [name, path.join(tmpDir, name)])),
);
}
it('skips POMs that cannot yield a child artifact identity', async () => {
await writeFile('broken/pom.xml', '<project><unclosed');
await writeFile('not-maven/pom.xml', '<notproject></notproject>');
await writeFile('empty/pom.xml', '<project></project>');
await writeFile(
'parent-only/pom.xml',
`<project>
<parent>
<groupId>com.example</groupId>
<artifactId>parent</artifactId>
<version>1</version>
</parent>
</project>`,
);
await writeFile('no-group/pom.xml', '<project><artifactId>orphan</artifactId></project>');
const result = await extractNamed(['broken', 'not-maven', 'empty', 'parent-only', 'no-group']);
expect(result.discoveredProjects.size).toBe(0);
expect(result.links).toHaveLength(0);
expect(result.discoveredProjects.has('parent-only')).toBe(false);
});
it('ignores dependencyManagement, profiles, and plugin dependencies for workspace links', async () => {
await writeFile('shared-lib/pom.xml', pomTemplate('com.example', 'shared-lib'));
await writeFile(
'shared-lib/src/main/java/com/example/shared/lib/SharedType.java',
'package com.example.shared.lib;\npublic class SharedType {}\n',
);
await writeFile(
'bom-consumer/pom.xml',
`<project>
<groupId>com.example</groupId>
<artifactId>bom-consumer</artifactId>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>shared-lib</artifactId>
<version>1</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>`,
);
await writeFile(
'bom-consumer/src/main/java/com/example/bom/App.java',
'package com.example.bom;\nimport com.example.shared.lib.SharedType;\npublic class App {}\n',
);
await writeFile(
'profile-consumer/pom.xml',
`<project>
<groupId>com.example</groupId>
<artifactId>profile-consumer</artifactId>
<profiles>
<profile>
<id>extra</id>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>shared-lib</artifactId>
</dependency>
</dependencies>
</profile>
</profiles>
</project>`,
);
await writeFile(
'profile-consumer/src/main/kotlin/com/example/profile/App.kt',
'package com.example.profile\nimport com.example.shared.lib.SharedType\nclass App\n',
);
await writeFile(
'plugin-consumer/pom.xml',
`<project>
<groupId>com.example</groupId>
<artifactId>plugin-consumer</artifactId>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>shared-lib</artifactId>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
</project>`,
);
await writeFile(
'plugin-consumer/src/main/java/com/example/plugin/App.java',
'package com.example.plugin;\nimport com.example.shared.lib.SharedType;\npublic class App {}\n',
);
const result = await extractNamed([
'shared-lib',
'bom-consumer',
'profile-consumer',
'plugin-consumer',
]);
expect(result.discoveredProjects.get('bom-consumer')?.deps).toEqual([]);
expect(result.discoveredProjects.get('profile-consumer')?.deps).toEqual([]);
expect(result.discoveredProjects.get('plugin-consumer')?.deps).toEqual([]);
expect(result.links).toEqual([]);
});
it('uses an explicit child groupId instead of its Maven parent groupId', async () => {
await writeFile(
'service/pom.xml',
`<project>
<parent>
<groupId>com.parent</groupId>
<artifactId>parent</artifactId>
<version>1</version>
</parent>
<groupId>com.child</groupId>
<artifactId>service</artifactId>
</project>`,
);
const result = await extractJavaWorkspaceLinks(
{ service: 'service' },
new Map([['service', path.join(tmpDir, 'service')]]),
);
expect(result.discoveredProjects.get('service')).toMatchObject({
groupId: 'com.child',
artifactId: 'service',
});
});
it('parses namespaced POM coordinates and CDATA text with a real XML parser', async () => {
await writeFile(
'lib/pom.xml',
`<?xml version="1.0"?>
<m:project xmlns:m="http://maven.apache.org/POM/4.0.0">
<m:parent>
<m:groupId>com.parent</m:groupId>
<m:artifactId>parent</m:artifactId>
</m:parent>
<m:artifactId><![CDATA[shared-lib]]></m:artifactId>
<m:dependencies>
<m:dependency>
<m:groupId>com.acme</m:groupId>
<m:artifactId>models</m:artifactId>
</m:dependency>
</m:dependencies>
</m:project>`,
);
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(
'lib/src/main/java/com/parent/shared/lib/App.java',
'package com.parent.shared.lib;\nimport com.acme.models.User;\npublic class App {}\n',
);
const result = await extractJavaWorkspaceLinks(
{ lib: 'lib', models: 'models' },
new Map([
['lib', path.join(tmpDir, 'lib')],
['models', path.join(tmpDir, 'models')],
]),
);
expect(result.discoveredProjects.get('lib')).toMatchObject({
groupId: 'com.parent',
artifactId: 'shared-lib',
});
expect(result.links).toEqual([
{
from: 'models',
to: 'lib',
type: 'custom',
contract: 'models::User',
role: 'provider',
},
]);
});
it('still rejects genuinely duplicate effective Maven coordinates', async () => {
await writeFile('first/pom.xml', inheritedPomTemplate('shared-lib'));
await writeFile('second/pom.xml', inheritedPomTemplate('shared-lib'));
const result = await extractJavaWorkspaceLinks(
{ first: 'first', second: 'second' },
new Map([
['first', path.join(tmpDir, 'first')],
['second', path.join(tmpDir, 'second')],
]),
);
expect(result.discoveredProjects.size).toBe(1);
expect(result.discoveredProjects.has('first')).toBe(true);
expect(result.discoveredProjects.has('second')).toBe(false);
});
it('handles Gradle build files', async () => {
await writeFile('core/build.gradle.kts', 'group = "com.acme"\nversion = "1.0"\n');
await writeFile(
@ -74,8 +366,8 @@ describe('JavaWorkspaceExtractor', () => {
'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',
'svc/src/main/kotlin/com/acme/svc/App.kt',
'package com.acme.svc\nimport com.acme.core.Config\nclass App\n',
);
const repos = { core: 'core', svc: 'svc' };
@ -118,6 +410,330 @@ describe('JavaWorkspaceExtractor', () => {
expect(result.links[0].contract).toBe('common::Entity');
});
it('reads Gradle group from gradle.properties and Groovy setter syntax', async () => {
await writeFile('core/gradle.properties', 'group=com.acme\n');
await writeFile('core/build.gradle', 'plugins { id "java" }\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',
"group 'com.acme'\ndependencies {\n testImplementation 'com.acme:core:1.0'\n}\n",
);
await writeFile(
'svc/src/test/kotlin/com/acme/svc/AppTest.kt',
'package com.acme.svc\nimport com.acme.core.Config\nclass AppTest\n',
);
const result = await extractNamed(['core', 'svc']);
expect(result.discoveredProjects.get('core')).toMatchObject({
groupId: 'com.acme',
artifactId: 'core',
});
expect(result.links).toEqual([
{
from: 'core',
to: 'svc',
type: 'custom',
contract: 'core::Config',
role: 'provider',
},
]);
});
it('reads Gradle group assigned inside allprojects { }', async () => {
await writeFile('core/build.gradle', 'allprojects { group = "com.acme" }\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',
'allprojects { group = "com.acme" }\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 result = await extractNamed(['core', 'svc']);
expect(result.discoveredProjects.get('core')).toMatchObject({
groupId: 'com.acme',
artifactId: 'core',
});
expect(result.links).toEqual([
{
from: 'core',
to: 'svc',
type: 'custom',
contract: 'core::Config',
role: 'provider',
},
]);
});
it('uses settings.gradle rootProject.name as the Gradle artifactId', async () => {
await writeFile('checkout/settings.gradle.kts', 'rootProject.name = "shared-core"\n');
await writeFile('checkout/build.gradle.kts', 'group = "com.acme"\n');
await writeFile(
'checkout/src/main/java/com/acme/shared/core/Flag.java',
'package com.acme.shared.core;\npublic class Flag {}\n',
);
await writeFile(
'app/build.gradle.kts',
'group = "com.acme"\ndependencies {\n implementation("com.acme:shared-core:1.0")\n}\n',
);
await writeFile(
'app/src/main/kotlin/com/acme/app/Main.kt',
'package com.acme.app\nimport com.acme.shared.core.Flag\nclass Main\n',
);
const result = await extractJavaWorkspaceLinks(
{ checkout: 'checkout', app: 'app' },
new Map([
['checkout', path.join(tmpDir, 'checkout')],
['app', path.join(tmpDir, 'app')],
]),
);
expect(result.discoveredProjects.get('checkout')?.artifactId).toBe('shared-core');
expect(result.links).toEqual([
{
from: 'checkout',
to: 'app',
type: 'custom',
contract: 'shared-core::Flag',
role: 'provider',
},
]);
});
it('resolves Gradle version-catalog and named group/name coordinates', async () => {
await writeFile('shared-lib/build.gradle.kts', 'group = "com.example"\n');
await writeFile(
'shared-lib/src/main/java/com/example/shared/lib/SharedType.java',
'package com.example.shared.lib;\npublic class SharedType {}\n',
);
await writeFile('models/build.gradle.kts', 'group = "com.example"\n');
await writeFile(
'models/src/main/java/com/example/models/User.java',
'package com.example.models;\npublic class User {}\n',
);
await writeFile(
'app/gradle/libs.versions.toml',
`[libraries]
shared-lib = { module = "com.example:shared-lib", version = "1.0" }
models = { group = "com.example", name = "models", version.ref = "unused" }
[bundles]
workspace = ["shared-lib", "models"]
`,
);
await writeFile(
'app/build.gradle.kts',
`group = "com.example"
dependencies {
implementation(libs.shared.lib)
implementation(libs.bundles.workspace)
}
`,
);
await writeFile(
'app/src/main/kotlin/com/example/app/App.kt',
'package com.example.app\nimport com.example.shared.lib.SharedType\nimport com.example.models.User\nclass App\n',
);
await writeFile(
'named/build.gradle',
`group = 'com.example'
dependencies {
implementation group: 'com.example', name: 'shared-lib', version: '1.0'
}
`,
);
await writeFile(
'named-first/build.gradle',
`group = 'com.example'
dependencies {
implementation name: 'shared-lib', group: 'com.example', version: '1.0'
}
`,
);
await writeFile(
'named/src/main/java/com/example/named/App.java',
'package com.example.named;\nimport com.example.shared.lib.SharedType;\npublic class App {}\n',
);
await writeFile(
'named-first/src/main/java/com/example/namedfirst/App.java',
'package com.example.namedfirst;\nimport com.example.shared.lib.SharedType;\npublic class App {}\n',
);
const result = await extractNamed(['shared-lib', 'models', 'app', 'named', 'named-first']);
expect(result.discoveredProjects.get('app')?.deps.sort()).toEqual([
'com.example:models',
'com.example:shared-lib',
]);
expect(result.links).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'shared-lib',
to: 'app',
contract: 'shared-lib::SharedType',
}),
expect.objectContaining({ from: 'models', to: 'app', contract: 'models::User' }),
expect.objectContaining({
from: 'shared-lib',
to: 'named',
contract: 'shared-lib::SharedType',
}),
expect.objectContaining({
from: 'shared-lib',
to: 'named-first',
contract: 'shared-lib::SharedType',
}),
]),
);
expect(result.links).toHaveLength(4);
});
it('resolves version-catalog aliases that contain underscores', async () => {
await writeFile('shared-lib/build.gradle.kts', 'group = "com.example"\n');
await writeFile(
'shared-lib/src/main/java/com/example/shared/lib/SharedType.java',
'package com.example.shared.lib;\npublic class SharedType {}\n',
);
await writeFile(
'app/gradle/libs.versions.toml',
`[libraries]
foo_bar = { module = "com.example:shared-lib", version = "1.0" }
`,
);
await writeFile(
'app/build.gradle.kts',
`group = "com.example"
dependencies {
implementation(libs.foo_bar)
}
`,
);
await writeFile(
'app/src/main/kotlin/com/example/app/App.kt',
'package com.example.app\nimport com.example.shared.lib.SharedType\nclass App\n',
);
const result = await extractNamed(['shared-lib', 'app']);
expect(result.discoveredProjects.get('app')?.deps).toEqual(['com.example:shared-lib']);
expect(result.links).toEqual([
expect.objectContaining({
from: 'shared-lib',
to: 'app',
contract: 'shared-lib::SharedType',
}),
]);
});
it('resolves Kotlin DSL named args, catalog get(), and type-safe project accessors', async () => {
await writeFile('shared-lib/build.gradle.kts', 'group = "com.example"\n');
await writeFile(
'shared-lib/src/main/kotlin/com/example/shared/lib/SharedType.kt',
'package com.example.shared.lib\nclass SharedType\n',
);
await writeFile('models/build.gradle.kts', 'group = "com.example"\n');
await writeFile(
'models/src/main/kotlin/com/example/models/User.kt',
'package com.example.models\ndata class User(val id: Int)\n',
);
await writeFile(
'app/gradle/libs.versions.toml',
'[libraries]\nmodels = { group = "com.example", name = "models" }\n',
);
await writeFile(
'app/build.gradle.kts',
`group = "com.example"
kotlin {
sourceSets {
commonMain {
dependencies {
implementation(name = "shared-lib", group = "com.example", version = "1.0")
implementation(projects.sharedLib)
implementation(libs.models.get())
ksp(libs.models)
}
}
}
}
`,
);
await writeFile(
'app/src/commonMain/kotlin/com/example/app/App.kt',
'package com.example.app\nimport com.example.shared.lib.SharedType as ST\nimport com.example.models.User\nclass App(val user: User, val shared: ST)\n',
);
const result = await extractNamed(['shared-lib', 'models', 'app']);
expect(result.discoveredProjects.get('app')?.deps.sort()).toEqual([
'com.example:models',
'com.example:shared-lib',
]);
expect(result.links).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'shared-lib',
to: 'app',
contract: 'shared-lib::SharedType',
}),
expect.objectContaining({ from: 'models', to: 'app', contract: 'models::User' }),
]),
);
expect(result.links).toHaveLength(2);
});
it('discovers Maven and Gradle projects together without changing Gradle identity', async () => {
await writeFile('shared-lib/pom.xml', pomTemplate('com.example', 'shared-lib'));
await writeFile(
'shared-lib/src/main/java/com/example/shared/lib/SharedType.java',
'package com.example.shared.lib;\npublic class SharedType {}\n',
);
await writeFile(
'gradle-app/build.gradle.kts',
'group = "com.example"\ndependencies {\n implementation("com.example:shared-lib:1.0")\n}\n',
);
await writeFile(
'gradle-app/src/main/kotlin/com/example/gradle/app/App.kt',
'package com.example.gradle.app\nimport com.example.shared.lib.SharedType\nclass App\n',
);
const result = await extractJavaWorkspaceLinks(
{ 'shared-lib': 'shared-lib', 'gradle-app': 'gradle-app' },
new Map([
['shared-lib', path.join(tmpDir, 'shared-lib')],
['gradle-app', path.join(tmpDir, 'gradle-app')],
]),
);
expect(result.discoveredProjects.get('gradle-app')).toMatchObject({
groupId: 'com.example',
artifactId: 'gradle-app',
});
expect(result.links).toEqual([
{
from: 'shared-lib',
to: 'gradle-app',
type: 'custom',
contract: 'shared-lib::SharedType',
role: 'provider',
},
]);
});
it('handles static imports', async () => {
await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib'));
await writeFile(

View file

@ -838,6 +838,86 @@ service OrderService {
expect(manifestLinks[0].to.repo).toBe('parser/mathlex');
});
it('builds Maven manifest links when independent repositories share a parent POM', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-maven-parent-'));
const parentCoordinates = `<parent>
<groupId>com.example</groupId>
<artifactId>parent</artifactId>
<version>1</version>
</parent>`;
const childPom = (artifactId: string, dependency = '') => `<project>
${parentCoordinates}
<artifactId>${artifactId}</artifactId>
<dependencies>${dependency}</dependencies>
</project>`;
const sharedDependency =
'<dependency><groupId>com.example</groupId><artifactId>shared-lib</artifactId></dependency>';
writeFileSync(
'parent/pom.xml',
'<project><groupId>com.example</groupId><artifactId>parent</artifactId><packaging>pom</packaging></project>',
);
writeFileSync('shared-lib/pom.xml', childPom('shared-lib'));
writeFileSync('service-a/pom.xml', childPom('service-a', sharedDependency));
writeFileSync(
'service-a/src/main/java/com/example/service/a/App.java',
'package com.example.service.a;\nimport com.example.shared.lib.SharedType;\npublic class App {}\n',
);
writeFileSync('service-b/pom.xml', childPom('service-b', sharedDependency));
writeFileSync(
'service-b/src/main/kotlin/com/example/service/b/App.kt',
'package com.example.service.b\nimport com.example.shared.lib.SharedType\nclass App\n',
);
const repoPaths = ['parent', 'shared-lib', 'service-a', 'service-b'];
const mockEntries: RegistryEntry[] = repoPaths.map((repoPath) => ({
name: repoPath,
path: path.join(tmpDir, repoPath),
storagePath: path.join(tmpDir, repoPath, '.gitnexus'),
indexedAt: '',
lastCommit: '',
}));
const repoManager = await import('../../../src/storage/repo-manager.js');
vi.spyOn(repoManager, 'readRegistry').mockResolvedValue(mockEntries);
const config = makeWsConfig(
{
parent: 'parent',
'libs/shared-lib': 'shared-lib',
'services/service-a': 'service-a',
'services/service-b': 'service-b',
},
true,
);
const result = await syncGroup(config, {
extractorOverride: async () => [],
skipWrite: true,
});
const manifestLinks = result.crossLinks.filter((link) => link.matchType === 'manifest');
expect(
manifestLinks.map((link) => ({
from: link.from.repo,
to: link.to.repo,
contractId: link.contractId,
})),
).toEqual([
{
from: 'services/service-a',
to: 'libs/shared-lib',
contractId: 'custom::shared-lib::SharedType',
},
{
from: 'services/service-b',
to: 'libs/shared-lib',
contractId: 'custom::shared-lib::SharedType',
},
]);
});
it('workspace_deps: false skips workspace extraction entirely', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-off-'));