From e04e1ecc6581f9503b0f3691c0f01a7a48398349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 31 Aug 2026 16:49:43 +0100 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Gergo Magyar Co-authored-by: Cursor Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- gitnexus/package-lock.json | 121 ++++ gitnexus/package.json | 1 + .../extractors/java-workspace-extractor.ts | 314 ++++++++- .../group/java-workspace-extractor.test.ts | 620 +++++++++++++++++- gitnexus/test/unit/group/sync.test.ts | 80 +++ 5 files changed, 1099 insertions(+), 37 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index bd3ebd088..0907115e4 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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", diff --git a/gitnexus/package.json b/gitnexus/package.json index cf511b17b..85a03befa 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -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", diff --git a/gitnexus/src/core/group/extractors/java-workspace-extractor.ts b/gitnexus/src/core/group/extractors/java-workspace-extractor.ts index b6beed71c..fcbb06507 100644 --- a/gitnexus/src/core/group/extractors/java-workspace-extractor.ts +++ b/gitnexus/src/core/group/extractors/java-workspace-extractor.ts @@ -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; + +// 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(/]*>[\s\S]*?([^<]+)<\/groupId>/); - const projectArtifactMatch = content.match( - /]*>[\s\S]*?([^<]+)<\/artifactId>/, - ); - if (!projectGroupMatch || !projectArtifactMatch) return null; +interface GradleSidecars { + propertiesGroup?: string; + rootProjectName?: string; + catalogLibraries: Map; + catalogBundles: Map; +} - const groupId = projectGroupMatch[1].trim(); - const artifactId = projectArtifactMatch[1].trim(); +async function readIfPresent(filePath: string): Promise { + try { + return await fs.readFile(filePath, 'utf-8'); + } catch { + return undefined; + } +} - 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()}`); +async function readGradleSidecars(repoPath: string): Promise { + 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 { + const fields: Record = {}; + 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; + bundles: Map; +} { + const libraries = new Map(); + const bundles = new Map(); + 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 , 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*['"](?[^'"]+)['"]\\s*,\\s*name\\s*=\\s*['"](?[^'"]+)['"]|name\\s*=\\s*['"](?[^'"]+)['"]\\s*,\\s*group\\s*=\\s*['"](?[^'"]+)['"]|group:\\s*['"](?[^'"]+)['"]\\s*,\\s*name:\\s*['"](?[^'"]+)['"]|name:\\s*['"](?[^'"]+)['"]\\s*,\\s*group:\\s*['"](?[^'"]+)['"])`, ); - 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)] }; diff --git a/gitnexus/test/unit/group/java-workspace-extractor.test.ts b/gitnexus/test/unit/group/java-workspace-extractor.test.ts index 09233ab12..fa8c3729f 100644 --- a/gitnexus/test/unit/group/java-workspace-extractor.test.ts +++ b/gitnexus/test/unit/group/java-workspace-extractor.test.ts @@ -31,6 +31,24 @@ describe('JavaWorkspaceExtractor', () => { return `${g}${a}${depXml}`; }; + const inheritedPomTemplate = (artifactId: string, deps: string[] = []) => { + const depXml = deps + .map((d) => { + const [gid, aid] = d.split(':'); + return `${gid}${aid}`; + }) + .join('\n'); + return ` + + com.example + parent + 1 + + ${artifactId} + ${depXml} + `; + }; + 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', ''); + await writeFile('empty/pom.xml', ''); + await writeFile( + 'parent-only/pom.xml', + ` + + com.example + parent + 1 + + `, + ); + await writeFile('no-group/pom.xml', 'orphan'); + + 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', + ` + com.example + bom-consumer + + + + com.example + shared-lib + 1 + + + + `, + ); + 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', + ` + com.example + profile-consumer + + + extra + + + com.example + shared-lib + + + + + `, + ); + 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', + ` + com.example + plugin-consumer + + + + org.apache.maven.plugins + maven-compiler-plugin + + + com.example + shared-lib + + + + + + `, + ); + 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', + ` + + com.parent + parent + 1 + + com.child + service + `, + ); + + 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', + ` + + + com.parent + parent + + + + + com.acme + models + + + `, + ); + 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( diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index 95bdd96d7..91b22f15f 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -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 = ` + com.example + parent + 1 + `; + const childPom = (artifactId: string, dependency = '') => ` + ${parentCoordinates} + ${artifactId} + ${dependency} + `; + const sharedDependency = + 'com.exampleshared-lib'; + + writeFileSync( + 'parent/pom.xml', + 'com.exampleparentpom', + ); + 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-'));