mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
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:
parent
19f6731c34
commit
e04e1ecc65
5 changed files with 1099 additions and 37 deletions
121
gitnexus/package-lock.json
generated
121
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)] };
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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-'));
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue