From fbab19e21ea36932091248c6a04c556ed874f905 Mon Sep 17 00:00:00 2001 From: RahulGautamSingh Date: Wed, 8 Apr 2026 03:27:53 +0545 Subject: [PATCH 01/11] new changes --- lib/modules/manager/ant/extract.spec.ts | 676 +++++++++++++++++++----- lib/modules/manager/ant/extract.ts | 357 ++++++++++++- lib/modules/manager/ant/types.ts | 5 + 3 files changed, 898 insertions(+), 140 deletions(-) create mode 100644 lib/modules/manager/ant/types.ts diff --git a/lib/modules/manager/ant/extract.spec.ts b/lib/modules/manager/ant/extract.spec.ts index 8ccc5b34e3f..9fd0c30d31e 100644 --- a/lib/modules/manager/ant/extract.spec.ts +++ b/lib/modules/manager/ant/extract.spec.ts @@ -1,157 +1,593 @@ import { codeBlock } from 'common-tags'; import { fs } from '~test/util.ts'; -import { extractAllPackageFiles, extractPackageFile } from './extract.ts'; +import { + extractAllPackageFiles, + extractPackageFile, + parsePropertiesFile, +} from './extract.ts'; +import type { AntProp } from './types.ts'; vi.mock('../../../util/fs/index.ts'); describe('modules/manager/ant/extract', () => { - it('extracts inline version dependencies from build.xml', () => { - expect( - extractPackageFile( - codeBlock` - - - - - - `, - 'build.xml', - ), - ).toEqual({ - deps: [ - expect.objectContaining({ - datasource: 'maven', - depName: 'junit:junit', - currentValue: '4.13.2', - depType: 'test', - registryUrls: [], - }), - ], - }); + beforeEach(() => { + vi.resetAllMocks(); }); - it('extracts multiple dependencies', () => { - expect( - extractPackageFile( - codeBlock` - - - - - - - - `, - 'build.xml', - ), - ).toMatchObject({ - deps: [ - expect.objectContaining({ - depName: 'junit:junit', - currentValue: '4.13.2', - depType: 'test', - }), - expect.objectContaining({ - depName: 'org.slf4j:slf4j-api', - currentValue: '1.7.36', - depType: 'compile', - }), - expect.objectContaining({ - depName: 'org.apache.commons:commons-lang3', - currentValue: '3.12.0', - depType: 'runtime', - }), - ], + describe('extractPackageFile', () => { + it('extracts inline version dependencies from build.xml', () => { + expect( + extractPackageFile( + codeBlock` + + + + + + `, + 'build.xml', + ), + ).toEqual({ + deps: [ + expect.objectContaining({ + datasource: 'maven', + depName: 'junit:junit', + currentValue: '4.13.2', + depType: 'test', + registryUrls: [], + }), + ], + }); + }); + + it('extracts multiple dependencies', () => { + expect( + extractPackageFile( + codeBlock` + + + + + + + + `, + 'build.xml', + ), + ).toMatchObject({ + deps: [ + expect.objectContaining({ + depName: 'junit:junit', + currentValue: '4.13.2', + depType: 'test', + }), + expect.objectContaining({ + depName: 'org.slf4j:slf4j-api', + currentValue: '1.7.36', + depType: 'compile', + }), + expect.objectContaining({ + depName: 'org.apache.commons:commons-lang3', + currentValue: '3.12.0', + depType: 'runtime', + }), + ], + }); + }); + + it('defaults depType to compile when no scope is set', () => { + expect( + extractPackageFile( + codeBlock` + + + + + + `, + 'build.xml', + ), + ).toEqual({ + deps: [ + expect.objectContaining({ + depName: 'junit:junit', + depType: 'compile', + }), + ], + }); + }); + + it('returns null for invalid XML', () => { + expect(extractPackageFile('<<< not xml >>>', 'build.xml')).toBeNull(); + }); + + it('returns null for build.xml with no dependencies', async () => { + fs.readLocalFile.mockResolvedValue( + '', + ); + + await expect( + extractAllPackageFiles({}, ['build.xml']), + ).resolves.toBeNull(); + }); + + it('ignores dependency nodes without version', () => { + expect( + extractPackageFile( + codeBlock` + + + + + + `, + 'build.xml', + ), + ).toBeNull(); + }); + + it('extracts dependencies with single-quoted attributes', () => { + expect( + extractPackageFile( + "", + 'build.xml', + ), + ).toEqual({ + deps: [ + expect.objectContaining({ + depName: 'junit:junit', + currentValue: '4.13.2', + }), + ], + }); }); - }); - it('defaults depType to compile when no scope is set', () => { - expect( - extractPackageFile( - codeBlock` + it('returns null for unreadable build.xml', async () => { + fs.readLocalFile.mockResolvedValue(null); + + await expect( + extractAllPackageFiles({}, ['build.xml']), + ).resolves.toBeNull(); + }); + + it('does not revisit the same file', async () => { + let readCount = 0; + fs.readLocalFile.mockImplementation(() => { + readCount++; + return Promise.resolve(codeBlock` - `, + `); + }); + + const result = await extractAllPackageFiles({}, [ 'build.xml', - ), - ).toEqual({ - deps: [ - expect.objectContaining({ - depName: 'junit:junit', - depType: 'compile', - }), - ], + 'build.xml', + ]); + + expect(result).toHaveLength(1); + expect(readCount).toBe(1); }); }); - it('returns null for invalid XML', () => { - expect(extractPackageFile('<<< not xml >>>', 'build.xml')).toBeNull(); - }); + describe('property resolution', () => { + it('resolves inline property references', async () => { + fs.readLocalFile.mockResolvedValue(codeBlock` + + + + + + + `); - it('returns null for build.xml with no dependencies', async () => { - fs.readLocalFile.mockResolvedValue( - '', - ); + const result = await extractAllPackageFiles({}, ['build.xml']); - await expect(extractAllPackageFiles({}, ['build.xml'])).resolves.toBeNull(); - }); + expect(result).toEqual([ + { + packageFile: 'build.xml', + deps: [ + expect.objectContaining({ + depName: 'org.slf4j:slf4j-api', + currentValue: '1.7.36', + sharedVariableName: 'slf4j.version', + }), + ], + }, + ]); + }); - it('ignores dependency nodes without version', () => { - expect( - extractPackageFile( - codeBlock` - - - - - - `, - 'build.xml', - ), - ).toBeNull(); - }); + it('resolves properties from external .properties files', async () => { + fs.readLocalFile.mockImplementation((file: string) => { + if (file === 'build.xml') { + return Promise.resolve(codeBlock` + + + + + + + `); + } + if (file === 'versions.properties') { + return Promise.resolve('slf4j.version=1.7.36\n'); + } + return Promise.resolve(null); + }); - it('extracts dependencies with single-quoted attributes', () => { - expect( - extractPackageFile( - "", - 'build.xml', - ), - ).toEqual({ - deps: [ - expect.objectContaining({ - depName: 'junit:junit', - currentValue: '4.13.2', - }), - ], + const result = await extractAllPackageFiles({}, ['build.xml']); + + expect(result).toEqual([ + { + packageFile: 'versions.properties', + deps: [ + expect.objectContaining({ + depName: 'org.slf4j:slf4j-api', + currentValue: '1.7.36', + sharedVariableName: 'slf4j.version', + editFile: 'versions.properties', + }), + ], + }, + ]); }); - }); - it('returns null for unreadable build.xml', async () => { - fs.readLocalFile.mockResolvedValue(null); + it('implements first-definition-wins for inline properties', async () => { + fs.readLocalFile.mockResolvedValue(codeBlock` + + + + + + + + `); + + const result = await extractAllPackageFiles({}, ['build.xml']); - await expect(extractAllPackageFiles({}, ['build.xml'])).resolves.toBeNull(); - }); + expect(result).toEqual([ + { + packageFile: 'build.xml', + deps: [ + expect.objectContaining({ + currentValue: '4.13.2', + sharedVariableName: 'junit.version', + }), + ], + }, + ]); + }); + + it('inline properties take precedence over file properties', async () => { + fs.readLocalFile.mockImplementation((file: string) => { + if (file === 'build.xml') { + return Promise.resolve(codeBlock` + + + + + + + + `); + } + if (file === 'versions.properties') { + return Promise.resolve('junit.version=4.12\n'); + } + return Promise.resolve(null); + }); - it('does not revisit the same file', async () => { - let readCount = 0; - fs.readLocalFile.mockImplementation(() => { - readCount++; - return Promise.resolve(codeBlock` + const result = await extractAllPackageFiles({}, ['build.xml']); + + expect(result).toEqual([ + { + packageFile: 'build.xml', + deps: [ + expect.objectContaining({ + currentValue: '4.13.2', + sharedVariableName: 'junit.version', + }), + ], + }, + ]); + }); + + it('skips dependencies with unresolvable property references', async () => { + fs.readLocalFile.mockResolvedValue(codeBlock` - + `); + + const result = await extractAllPackageFiles({}, ['build.xml']); + + expect(result).toEqual([ + { + packageFile: 'build.xml', + deps: [ + expect.objectContaining({ + depName: 'junit:junit', + skipReason: 'version-placeholder', + }), + ], + }, + ]); + }); + + it('detects circular property references', async () => { + fs.readLocalFile.mockResolvedValue(codeBlock` + + + + + + + + `); + + const result = await extractAllPackageFiles({}, ['build.xml']); + + expect(result).toEqual([ + { + packageFile: 'build.xml', + deps: [ + expect.objectContaining({ + depName: 'junit:junit', + skipReason: 'recursive-placeholder', + }), + ], + }, + ]); + }); + + it('resolves chained property references', async () => { + fs.readLocalFile.mockResolvedValue(codeBlock` + + + + + + + + + `); + + const result = await extractAllPackageFiles({}, ['build.xml']); + + // chained partial resolution: full.version = "1.7.36" (resolved chain) + // but slf4j.version = "${full.version}" -> "1.7.36" (single prop ref, so sharedVariableName) + expect(result).toEqual([ + { + packageFile: 'build.xml', + deps: [ + expect.objectContaining({ + depName: 'org.slf4j:slf4j-api', + currentValue: '1.7.36', + sharedVariableName: 'slf4j.version', + }), + ], + }, + ]); + }); + + it('groups multiple dependencies sharing the same property', async () => { + fs.readLocalFile.mockResolvedValue(codeBlock` + + + + + + + + `); + + const result = await extractAllPackageFiles({}, ['build.xml']); + + expect(result).toEqual([ + { + packageFile: 'build.xml', + deps: [ + expect.objectContaining({ + depName: 'com.fasterxml.jackson.core:jackson-core', + currentValue: '2.15.2', + sharedVariableName: 'jackson.version', + }), + expect.objectContaining({ + depName: 'com.fasterxml.jackson.core:jackson-databind', + currentValue: '2.15.2', + sharedVariableName: 'jackson.version', + }), + ], + }, + ]); + }); + + it('handles properties file in subdirectory', async () => { + fs.readLocalFile.mockImplementation((file: string) => { + if (file === 'subproject/build.xml') { + return Promise.resolve(codeBlock` + + + + + + + `); + } + if (file === 'subproject/config/deps.properties') { + return Promise.resolve('junit.version=4.13.2\n'); + } + return Promise.resolve(null); + }); + + const result = await extractAllPackageFiles({}, ['subproject/build.xml']); + + expect(result).toEqual([ + { + packageFile: 'subproject/config/deps.properties', + deps: [ + expect.objectContaining({ + depName: 'junit:junit', + currentValue: '4.13.2', + sharedVariableName: 'junit.version', + }), + ], + }, + ]); + }); + + it('handles unreadable properties file gracefully', async () => { + fs.readLocalFile.mockImplementation((file: string) => { + if (file === 'build.xml') { + return Promise.resolve(codeBlock` + + + + + + + `); + } + return Promise.resolve(null); + }); + + const result = await extractAllPackageFiles({}, ['build.xml']); + + expect(result).toEqual([ + { + packageFile: 'build.xml', + deps: [ + expect.objectContaining({ + depName: 'junit:junit', + skipReason: 'version-placeholder', + }), + ], + }, + ]); }); - const result = await extractAllPackageFiles({}, ['build.xml', 'build.xml']); + it('returns deps with mixed inline and property versions', async () => { + fs.readLocalFile.mockResolvedValue(codeBlock` + + + + + + + + `); + + const result = await extractAllPackageFiles({}, ['build.xml']); + + expect(result).toEqual([ + { + packageFile: 'build.xml', + deps: [ + expect.objectContaining({ + depName: 'junit:junit', + currentValue: '4.13.2', + sharedVariableName: 'junit.version', + }), + expect.objectContaining({ + depName: 'org.slf4j:slf4j-api', + currentValue: '1.7.36', + }), + ], + }, + ]); + }); + + it('skips partial placeholder in version string', async () => { + fs.readLocalFile.mockResolvedValue(codeBlock` + + + + + + + `); + + const result = await extractAllPackageFiles({}, ['build.xml']); + + expect(result).toEqual([ + { + packageFile: 'build.xml', + deps: [ + expect.objectContaining({ + depName: 'org.slf4j:slf4j-api', + skipReason: 'version-placeholder', + }), + ], + }, + ]); + }); + }); - expect(result).toHaveLength(1); - expect(readCount).toBe(1); + describe('parsePropertiesFile', () => { + it('parses key=value pairs', () => { + const props: Record = {}; + parsePropertiesFile( + 'key1=value1\nkey2=value2\n', + 'test.properties', + props, + ); + + expect(props.key1).toEqual( + expect.objectContaining({ + val: 'value1', + packageFile: 'test.properties', + }), + ); + expect(props.key2).toEqual( + expect.objectContaining({ + val: 'value2', + packageFile: 'test.properties', + }), + ); + }); + + it('skips comments and blank lines', () => { + const props: Record = {}; + parsePropertiesFile( + '# comment\n\nkey=value\n! another comment\n', + 'test.properties', + props, + ); + + expect(Object.keys(props)).toEqual(['key']); + }); + + it('supports colon separator', () => { + const props: Record = {}; + parsePropertiesFile('key:value\n', 'test.properties', props); + + expect(props.key).toEqual(expect.objectContaining({ val: 'value' })); + }); + + it('implements first-definition-wins', () => { + const props: Record = {}; + parsePropertiesFile('key=first\nkey=second\n', 'test.properties', props); + + expect(props.key.val).toBe('first'); + }); + + it('respects pre-existing props (first-definition-wins across sources)', () => { + const props: Record = { + key: { + val: 'existing', + fileReplacePosition: 0, + packageFile: 'build.xml', + }, + }; + parsePropertiesFile('key=new\n', 'test.properties', props); + + expect(props.key.val).toBe('existing'); + expect(props.key.packageFile).toBe('build.xml'); + }); }); }); diff --git a/lib/modules/manager/ant/extract.ts b/lib/modules/manager/ant/extract.ts index 5c314621a0b..c0c011c3eff 100644 --- a/lib/modules/manager/ant/extract.ts +++ b/lib/modules/manager/ant/extract.ts @@ -1,7 +1,9 @@ +import { dirname, join } from 'upath'; import type { XmlElement } from 'xmldoc'; import { XmlDocument } from 'xmldoc'; import { logger } from '../../../logger/index.ts'; import { readLocalFile } from '../../../util/fs/index.ts'; +import { regEx } from '../../../util/regex.ts'; import { MavenDatasource } from '../../datasource/maven/index.ts'; import { isXmlElement } from '../nuget/util.ts'; import type { @@ -10,6 +12,7 @@ import type { PackageFile, PackageFileContent, } from '../types.ts'; +import type { AntProp } from './types.ts'; const scopeNames = new Set([ 'compile', @@ -19,6 +22,9 @@ const scopeNames = new Set([ 'system', ]); +const placeholderRegex = regEx(/\$\{([^}]+)}/g); +const fullPlaceholderRegex = regEx(/^\$\{([^}]+)}$/); + function getDependencyType(scope: string | undefined): string { if (scope && scopeNames.has(scope)) { return scope; @@ -26,25 +32,172 @@ function getDependencyType(scope: string | undefined): string { return 'compile'; } -function collectDependency(node: XmlElement): PackageDependency | null { +const placeholderTestRegex = regEx(/\$\{[^}]+}/); + +function containsPlaceholder(str: string | null | undefined): boolean { + return !!str && placeholderTestRegex.test(str); +} + +/** + * Find the byte offset of an attribute's value in raw XML content. + * Returns the offset of the first character of the value (after the opening quote). + */ +function findAttrValuePosition( + content: string, + node: XmlElement, + attrName: string, +): number | null { + // Search from the node's start position in the content + const startTag = node.startTagPosition; + if (startTag === undefined || startTag === null) { + return null; + } + + // Find the closing of this element's start tag + const tagEnd = content.indexOf('>', startTag); + if (tagEnd === -1) { + return null; + } + const tagContent = content.slice(startTag, tagEnd + 1); + + // Match attrName="value" or attrName='value' + const attrPattern = regEx(`${attrName}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`); + const match = attrPattern.exec(tagContent); + if (!match) { + return null; + } + + const valueInMatch = match[1] ?? match[2]; + const valueOffset = match[0].indexOf(valueInMatch); + return startTag + match.index + valueOffset; +} + +/** + * Parse a .properties file into a map of property names to AntProp. + * Implements first-definition-wins: if a key already exists in the map, it is not overwritten. + */ +export function parsePropertiesFile( + content: string, + packageFile: string, + props: Record, +): void { + let offset = 0; + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + // Skip comments and blank lines + if (line.startsWith('#') || line.startsWith('!') || line === '') { + offset += rawLine.length + 1; // +1 for newline + continue; + } + + // Match key=value, key:value, or key value (first separator wins) + const separatorMatch = regEx(/^([^=:\s]+)\s*[=:\s]\s*(.*)$/).exec(line); + if (separatorMatch) { + const key = separatorMatch[1]; + const val = separatorMatch[2].trim(); + + // First-definition-wins + if (!(key in props)) { + // fileReplacePosition points to the start of the value in the raw content + const lineStart = offset + rawLine.indexOf(line); + const keyEnd = line.indexOf(separatorMatch[2]); + const fileReplacePosition = lineStart + keyEnd; + + props[key] = { val, fileReplacePosition, packageFile }; + } + } + + offset += rawLine.length + 1; + } +} + +interface RawDep { + dep: PackageDependency; + depPackageFile: string; +} + +/** + * Collect inline elements. + * Implements first-definition-wins. + */ +function collectProperties( + node: XmlElement | XmlDocument, + content: string, + packageFile: string, + props: Record, +): void { + for (const child of node.children) { + if (!isXmlElement(child)) { + continue; + } + + if (child.name === 'property') { + const name = child.attr.name; + const value = child.attr.value; + if (name && value && !(name in props)) { + const pos = findAttrValuePosition(content, child, 'value'); + if (pos !== null) { + props[name] = { val: value, fileReplacePosition: pos, packageFile }; + } + } + } + + collectProperties(child, content, packageFile, props); + } +} + +/** + * Collect references to external properties files. + */ +function collectPropertyFileRefs(node: XmlElement | XmlDocument): string[] { + const files: string[] = []; + for (const child of node.children) { + if (!isXmlElement(child)) { + continue; + } + + if (child.name === 'property' && child.attr.file) { + files.push(child.attr.file); + } + + files.push(...collectPropertyFileRefs(child)); + } + return files; +} + +function collectDependency( + node: XmlElement, + packageFile: string, + content: string, +): RawDep | null { const { groupId, artifactId, version, scope } = node.attr; if (!version || !groupId || !artifactId) { return null; } - return { + const dep: PackageDependency = { datasource: MavenDatasource.id, depName: `${groupId}:${artifactId}`, currentValue: version, depType: getDependencyType(scope), registryUrls: [], }; + + // Track the position of the version attribute value for inline versions + const pos = findAttrValuePosition(content, node, 'version'); + if (pos !== null) { + dep.fileReplacePosition = pos; + } + + return { dep, depPackageFile: packageFile }; } function walkNode( node: XmlElement | XmlDocument, - deps: PackageDependency[], + rawDeps: RawDep[], + packageFile: string, + content: string, ): void { for (const child of node.children) { if (!isXmlElement(child)) { @@ -52,16 +205,64 @@ function walkNode( } if (child.name === 'dependency') { - const dep = collectDependency(child); - if (dep) { - deps.push(dep); + const rawDep = collectDependency(child, packageFile, content); + if (rawDep) { + rawDeps.push(rawDep); } } else { - walkNode(child, deps); + walkNode(child, rawDeps, packageFile, content); } } } +/** + * Apply property resolution to a dependency. + * Handles chained references with circular detection. + */ +function applyProps( + rawDep: RawDep, + props: Record, +): PackageDependency { + const { dep, depPackageFile } = rawDep; + const currentValue = dep.currentValue; + + if (!currentValue || !containsPlaceholder(currentValue)) { + return dep; + } + + // Check if the entire version is a single property reference + const fullMatch = fullPlaceholderRegex.exec(currentValue); + if (!fullMatch) { + // Partial placeholder in version string - not supported for updates + dep.skipReason = 'version-placeholder'; + return dep; + } + + const propKey = fullMatch[1]; + const prop = props[propKey]; + if (!prop) { + dep.skipReason = 'version-placeholder'; + return dep; + } + + // After resolveChainedProps, prop.val is either fully resolved or still contains + // placeholders (meaning circular or unresolvable) + if (containsPlaceholder(prop.val)) { + dep.skipReason = 'recursive-placeholder'; + return dep; + } + + dep.currentValue = prop.val; + dep.sharedVariableName = propKey; + dep.fileReplacePosition = prop.fileReplacePosition; + if (prop.packageFile !== depPackageFile) { + dep.editFile = prop.packageFile; + } + // propSource is used to route deps to the correct PackageFile + dep.propSource = prop.packageFile; + return dep; +} + export function extractPackageFile( content: string, packageFile: string, @@ -74,8 +275,10 @@ export function extractPackageFile( return null; } - const deps: PackageDependency[] = []; - walkNode(doc, deps); + const rawDeps: RawDep[] = []; + walkNode(doc, rawDeps, packageFile, content); + + const deps = rawDeps.map((rd) => rd.dep); if (deps.length === 0) { return null; @@ -87,39 +290,153 @@ export function extractPackageFile( async function walkXmlFile( packageFile: string, visitedFiles: Set, -): Promise { + allProps: Record, + allRawDeps: RawDep[], +): Promise { if (visitedFiles.has(packageFile)) { - return null; + return; } visitedFiles.add(packageFile); const content = await readLocalFile(packageFile, 'utf8'); if (!content) { logger.debug(`ant manager: could not read ${packageFile}`); - return null; + return; } - const result = extractPackageFile(content, packageFile); - if (!result) { - return null; + let doc: XmlDocument; + try { + doc = new XmlDocument(content); + } catch { + logger.debug(`ant manager: could not parse XML ${packageFile}`); + return; } - return { packageFile, ...result }; + // Collect property file references first (order matters for first-definition-wins) + const propertyFileRefs = collectPropertyFileRefs(doc); + + // Collect inline properties (first-definition-wins: inline before file refs) + collectProperties(doc, content, packageFile, allProps); + + // Load external .properties files + const baseDir = dirname(packageFile); + for (const ref of propertyFileRefs) { + const propFilePath = ref.startsWith('/') ? ref : join(baseDir, ref); + + if (visitedFiles.has(propFilePath)) { + continue; + } + visitedFiles.add(propFilePath); + + const propContent = await readLocalFile(propFilePath, 'utf8'); + if (!propContent) { + logger.debug( + `ant manager: could not read properties file ${propFilePath}`, + ); + continue; + } + + parsePropertiesFile(propContent, propFilePath, allProps); + } + + // Collect dependencies + walkNode(doc, allRawDeps, packageFile, content); } export async function extractAllPackageFiles( _config: ExtractConfig, packageFiles: string[], ): Promise { - const results: PackageFile[] = []; const visitedFiles = new Set(); + const allProps: Record = {}; + const allRawDeps: RawDep[] = []; for (const packageFile of packageFiles) { - const result = await walkXmlFile(packageFile, visitedFiles); - if (result) { - results.push(result); + await walkXmlFile(packageFile, visitedFiles, allProps, allRawDeps); + } + + // Resolve chained property values before applying to deps + resolveChainedProps(allProps); + + // Apply property resolution to all dependencies + const resolvedDeps = allRawDeps.map((rawDep) => applyProps(rawDep, allProps)); + + if (resolvedDeps.length === 0) { + return null; + } + + // Group deps by their target file (propSource or original packageFile) + const fileMap = new Map(); + for (let i = 0; i < resolvedDeps.length; i++) { + const dep = resolvedDeps[i]; + const targetFile = dep.propSource ?? allRawDeps[i].depPackageFile; + if (!fileMap.has(targetFile)) { + fileMap.set(targetFile, []); + } + fileMap.get(targetFile)!.push(dep); + } + + const results: PackageFile[] = []; + for (const [packageFile, deps] of fileMap) { + // Clean up internal propSource field + for (const dep of deps) { + delete dep.propSource; } + results.push({ packageFile, deps }); } return results.length > 0 ? results : null; } + +/** + * Resolve chained property references within the property map itself. + * E.g., if prop A = "${B}" and prop B = "1.0", resolve A to "1.0". + * Marks circular properties by setting val to a placeholder that will be caught later. + */ +function resolveChainedProps(props: Record): void { + const resolved = new Map(); // null = circular + + function resolve(key: string, chain: Set): string | null { + if (resolved.has(key)) { + return resolved.get(key)!; + } + if (chain.has(key)) { + // Circular reference detected + resolved.set(key, null); + return null; + } + const prop = props[key]; + if (!prop) { + return null; + } + if (!containsPlaceholder(prop.val)) { + resolved.set(key, prop.val); + return prop.val; + } + + chain.add(key); + let isCircular = false; + const val = prop.val.replace(placeholderRegex, (match, refKey: string) => { + const refResult = resolve(refKey, chain); + if (refResult === null) { + isCircular = true; + return match; + } + return refResult; + }); + chain.delete(key); + + if (isCircular) { + resolved.set(key, null); + return null; + } + + resolved.set(key, val); + prop.val = val; + return val; + } + + for (const key of Object.keys(props)) { + resolve(key, new Set()); + } +} diff --git a/lib/modules/manager/ant/types.ts b/lib/modules/manager/ant/types.ts new file mode 100644 index 00000000000..04844ed62da --- /dev/null +++ b/lib/modules/manager/ant/types.ts @@ -0,0 +1,5 @@ +export interface AntProp { + val: string; + fileReplacePosition: number; + packageFile: string; +} From eaddd7d1c07c4b775da506289b35ffa15b23a483 Mon Sep 17 00:00:00 2001 From: RahulGautamSingh Date: Wed, 8 Apr 2026 15:52:43 +0545 Subject: [PATCH 02/11] use updateDependency --- lib/modules/manager/ant/extract.ts | 6 ++-- lib/modules/manager/ant/index.ts | 1 + lib/modules/manager/ant/update.ts | 50 ++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 lib/modules/manager/ant/update.ts diff --git a/lib/modules/manager/ant/extract.ts b/lib/modules/manager/ant/extract.ts index c0c011c3eff..37fef58251e 100644 --- a/lib/modules/manager/ant/extract.ts +++ b/lib/modules/manager/ant/extract.ts @@ -1,4 +1,4 @@ -import { dirname, join } from 'upath'; +import upath from 'upath'; import type { XmlElement } from 'xmldoc'; import { XmlDocument } from 'xmldoc'; import { logger } from '../../../logger/index.ts'; @@ -319,9 +319,9 @@ async function walkXmlFile( collectProperties(doc, content, packageFile, allProps); // Load external .properties files - const baseDir = dirname(packageFile); + const baseDir = upath.dirname(packageFile); for (const ref of propertyFileRefs) { - const propFilePath = ref.startsWith('/') ? ref : join(baseDir, ref); + const propFilePath = ref.startsWith('/') ? ref : upath.join(baseDir, ref); if (visitedFiles.has(propFilePath)) { continue; diff --git a/lib/modules/manager/ant/index.ts b/lib/modules/manager/ant/index.ts index 697f7efeb17..7d77055ef0b 100644 --- a/lib/modules/manager/ant/index.ts +++ b/lib/modules/manager/ant/index.ts @@ -2,6 +2,7 @@ import type { Category } from '../../../constants/index.ts'; import { MavenDatasource } from '../../datasource/maven/index.ts'; export { extractAllPackageFiles, extractPackageFile } from './extract.ts'; +export { updateDependency } from './update.ts'; export const displayName = 'Apache Ant'; export const url = 'https://ant.apache.org'; diff --git a/lib/modules/manager/ant/update.ts b/lib/modules/manager/ant/update.ts new file mode 100644 index 00000000000..eef9816b5db --- /dev/null +++ b/lib/modules/manager/ant/update.ts @@ -0,0 +1,50 @@ +import { logger } from '../../../logger/index.ts'; +import type { UpdateDependencyConfig } from '../types.ts'; + +export function updateDependency({ + fileContent, + upgrade, +}: UpdateDependencyConfig): string | null { + const { depName, currentValue, newValue, fileReplacePosition } = upgrade; + + if (fileReplacePosition === undefined || fileReplacePosition === null) { + logger.debug({ depName }, 'No fileReplacePosition for ant dependency'); + return null; + } + + const leftPart = fileContent.slice(0, fileReplacePosition); + const rightPart = fileContent.slice(fileReplacePosition); + + // Find the end of the value (closing quote or end of line for .properties files) + let endIndex: number; + // Check if we're inside an XML attribute (preceded by a quote) + const quoteChar = leftPart.at(-1); + if (quoteChar === '"' || quoteChar === "'") { + endIndex = rightPart.indexOf(quoteChar); + } else { + // .properties file: value ends at newline or EOF + const newlineIndex = rightPart.indexOf('\n'); + endIndex = newlineIndex === -1 ? rightPart.length : newlineIndex; + } + + if (endIndex === -1) { + logger.debug({ depName }, 'Could not find end of value'); + return null; + } + + const currentFound = rightPart.slice(0, endIndex); + + if (currentFound === newValue) { + return fileContent; + } + + if (currentFound === currentValue || upgrade.sharedVariableName) { + return `${leftPart}${newValue}${rightPart.slice(endIndex)}`; + } + + logger.debug( + { depName, currentFound, currentValue, newValue }, + 'ant: unexpected value at fileReplacePosition', + ); + return null; +} From 81f2a8ca445243dfbed09fef459aa031ad143468 Mon Sep 17 00:00:00 2001 From: RahulGautamSingh Date: Wed, 8 Apr 2026 20:31:46 +0545 Subject: [PATCH 03/11] fix coverage --- lib/modules/manager/ant/extract.spec.ts | 117 ++++++++++++++++++++ lib/modules/manager/ant/extract.ts | 34 ++---- lib/modules/manager/ant/update.spec.ts | 138 ++++++++++++++++++++++++ lib/modules/manager/ant/update.ts | 6 -- 4 files changed, 263 insertions(+), 32 deletions(-) create mode 100644 lib/modules/manager/ant/update.spec.ts diff --git a/lib/modules/manager/ant/extract.spec.ts b/lib/modules/manager/ant/extract.spec.ts index 9fd0c30d31e..1d7be5fd748 100644 --- a/lib/modules/manager/ant/extract.spec.ts +++ b/lib/modules/manager/ant/extract.spec.ts @@ -528,6 +528,112 @@ describe('modules/manager/ant/extract', () => { }); }); + describe('edge cases', () => { + it('handles unparseable XML returned by readLocalFile', async () => { + fs.readLocalFile.mockResolvedValue('<<< not xml >>>'); + + const result = await extractAllPackageFiles({}, ['build.xml']); + + expect(result).toBeNull(); + }); + + it('handles absolute path in property file reference', async () => { + fs.readLocalFile.mockImplementation((file: string) => { + if (file === 'build.xml') { + return Promise.resolve(codeBlock` + + + + + + + `); + } + if (file === '/absolute/versions.properties') { + return Promise.resolve('junit.version=4.13.2\n'); + } + return Promise.resolve(null); + }); + + const result = await extractAllPackageFiles({}, ['build.xml']); + + expect(result).toEqual([ + { + packageFile: '/absolute/versions.properties', + deps: [ + expect.objectContaining({ + depName: 'junit:junit', + currentValue: '4.13.2', + sharedVariableName: 'junit.version', + }), + ], + }, + ]); + }); + + it('skips duplicate property file references', async () => { + let propsReadCount = 0; + fs.readLocalFile.mockImplementation((file: string) => { + if (file === 'build.xml') { + return Promise.resolve(codeBlock` + + + + + + + + `); + } + if (file === 'versions.properties') { + propsReadCount++; + return Promise.resolve('junit.version=4.13.2\n'); + } + return Promise.resolve(null); + }); + + const result = await extractAllPackageFiles({}, ['build.xml']); + + expect(propsReadCount).toBe(1); + expect(result).toEqual([ + { + packageFile: 'versions.properties', + deps: [ + expect.objectContaining({ + depName: 'junit:junit', + currentValue: '4.13.2', + }), + ], + }, + ]); + }); + + it('handles chain referencing undefined property', async () => { + fs.readLocalFile.mockResolvedValue(codeBlock` + + + + + + + `); + + const result = await extractAllPackageFiles({}, ['build.xml']); + + expect(result).toEqual([ + { + packageFile: 'build.xml', + deps: [ + expect.objectContaining({ + depName: 'junit:junit', + skipReason: 'recursive-placeholder', + }), + ], + }, + ]); + }); + }); + describe('parsePropertiesFile', () => { it('parses key=value pairs', () => { const props: Record = {}; @@ -569,6 +675,17 @@ describe('modules/manager/ant/extract', () => { expect(props.key).toEqual(expect.objectContaining({ val: 'value' })); }); + it('skips malformed lines without separators', () => { + const props: Record = {}; + parsePropertiesFile( + 'key=value\nmalformed_line_no_separator\nother=val\n', + 'test.properties', + props, + ); + + expect(Object.keys(props)).toEqual(['key', 'other']); + }); + it('implements first-definition-wins', () => { const props: Record = {}; parsePropertiesFile('key=first\nkey=second\n', 'test.properties', props); diff --git a/lib/modules/manager/ant/extract.ts b/lib/modules/manager/ant/extract.ts index 37fef58251e..733aede3f93 100644 --- a/lib/modules/manager/ant/extract.ts +++ b/lib/modules/manager/ant/extract.ts @@ -33,6 +33,7 @@ function getDependencyType(scope: string | undefined): string { } const placeholderTestRegex = regEx(/\$\{[^}]+}/); +const propertySeparatorRegex = regEx(/^([^=:\s]+)\s*[=:\s]\s*(.*)$/); function containsPlaceholder(str: string | null | undefined): boolean { return !!str && placeholderTestRegex.test(str); @@ -46,26 +47,13 @@ function findAttrValuePosition( content: string, node: XmlElement, attrName: string, -): number | null { - // Search from the node's start position in the content - const startTag = node.startTagPosition; - if (startTag === undefined || startTag === null) { - return null; - } - - // Find the closing of this element's start tag +): number { + const startTag = node.startTagPosition!; const tagEnd = content.indexOf('>', startTag); - if (tagEnd === -1) { - return null; - } const tagContent = content.slice(startTag, tagEnd + 1); - // Match attrName="value" or attrName='value' const attrPattern = regEx(`${attrName}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`); - const match = attrPattern.exec(tagContent); - if (!match) { - return null; - } + const match = attrPattern.exec(tagContent)!; const valueInMatch = match[1] ?? match[2]; const valueOffset = match[0].indexOf(valueInMatch); @@ -91,7 +79,7 @@ export function parsePropertiesFile( } // Match key=value, key:value, or key value (first separator wins) - const separatorMatch = regEx(/^([^=:\s]+)\s*[=:\s]\s*(.*)$/).exec(line); + const separatorMatch = propertySeparatorRegex.exec(line); if (separatorMatch) { const key = separatorMatch[1]; const val = separatorMatch[2].trim(); @@ -136,9 +124,7 @@ function collectProperties( const value = child.attr.value; if (name && value && !(name in props)) { const pos = findAttrValuePosition(content, child, 'value'); - if (pos !== null) { - props[name] = { val: value, fileReplacePosition: pos, packageFile }; - } + props[name] = { val: value, fileReplacePosition: pos, packageFile }; } } @@ -184,11 +170,7 @@ function collectDependency( registryUrls: [], }; - // Track the position of the version attribute value for inline versions - const pos = findAttrValuePosition(content, node, 'version'); - if (pos !== null) { - dep.fileReplacePosition = pos; - } + dep.fileReplacePosition = findAttrValuePosition(content, node, 'version'); return { dep, depPackageFile: packageFile }; } @@ -385,7 +367,7 @@ export async function extractAllPackageFiles( results.push({ packageFile, deps }); } - return results.length > 0 ? results : null; + return results; } /** diff --git a/lib/modules/manager/ant/update.spec.ts b/lib/modules/manager/ant/update.spec.ts new file mode 100644 index 00000000000..417fdc01eb0 --- /dev/null +++ b/lib/modules/manager/ant/update.spec.ts @@ -0,0 +1,138 @@ +import { updateDependency } from './update.ts'; + +describe('modules/manager/ant/update', () => { + it('updates inline XML version attribute', () => { + const fileContent = + ''; + const result = updateDependency({ + fileContent, + packageFile: 'build.xml', + upgrade: { + depName: 'junit:junit', + currentValue: '4.13.1', + newValue: '4.13.2', + fileReplacePosition: fileContent.indexOf('4.13.1'), + }, + }); + + expect(result).toBe( + '', + ); + }); + + it('updates single-quoted XML version attribute', () => { + const fileContent = + ""; + const result = updateDependency({ + fileContent, + packageFile: 'build.xml', + upgrade: { + depName: 'junit:junit', + currentValue: '4.13.1', + newValue: '4.13.2', + fileReplacePosition: fileContent.indexOf('4.13.1'), + }, + }); + + expect(result).toBe( + "", + ); + }); + + it('updates .properties file value', () => { + const fileContent = 'junit.version=4.13.1\nother.key=value\n'; + const result = updateDependency({ + fileContent, + packageFile: 'versions.properties', + upgrade: { + depName: 'junit:junit', + currentValue: '4.13.1', + newValue: '4.13.2', + fileReplacePosition: fileContent.indexOf('4.13.1'), + }, + }); + + expect(result).toBe('junit.version=4.13.2\nother.key=value\n'); + }); + + it('updates .properties value at end of file without trailing newline', () => { + const fileContent = 'junit.version=4.13.1'; + const result = updateDependency({ + fileContent, + packageFile: 'versions.properties', + upgrade: { + depName: 'junit:junit', + currentValue: '4.13.1', + newValue: '4.13.2', + fileReplacePosition: fileContent.indexOf('4.13.1'), + }, + }); + + expect(result).toBe('junit.version=4.13.2'); + }); + + it('returns fileContent unchanged when already updated', () => { + const fileContent = + ''; + const result = updateDependency({ + fileContent, + packageFile: 'build.xml', + upgrade: { + depName: 'junit:junit', + currentValue: '4.13.1', + newValue: '4.13.2', + fileReplacePosition: fileContent.indexOf('4.13.2'), + }, + }); + + expect(result).toBe(fileContent); + }); + + it('updates when sharedVariableName is set even if currentValue differs', () => { + const fileContent = ''; + const result = updateDependency({ + fileContent, + packageFile: 'build.xml', + upgrade: { + depName: 'junit:junit', + currentValue: '4.13.0', + newValue: '4.13.2', + sharedVariableName: 'junit.version', + fileReplacePosition: fileContent.indexOf('4.13.1'), + }, + }); + + expect(result).toBe(''); + }); + + it('returns null when fileReplacePosition is undefined', () => { + const result = updateDependency({ + fileContent: '', + packageFile: 'build.xml', + upgrade: { + depName: 'org.example:lib', + currentValue: '1.0', + newValue: '2.0', + }, + }); + + expect(result).toBeNull(); + }); + + it('returns null when value at position does not match', () => { + const fileContent = + ''; + const result = updateDependency({ + fileContent, + packageFile: 'build.xml', + upgrade: { + depName: 'junit:junit', + currentValue: '4.13.1', + newValue: '4.13.2', + fileReplacePosition: fileContent.indexOf('9.9.9'), + }, + }); + + expect(result).toBeNull(); + }); +}); diff --git a/lib/modules/manager/ant/update.ts b/lib/modules/manager/ant/update.ts index eef9816b5db..fd1f1b7fc98 100644 --- a/lib/modules/manager/ant/update.ts +++ b/lib/modules/manager/ant/update.ts @@ -17,7 +17,6 @@ export function updateDependency({ // Find the end of the value (closing quote or end of line for .properties files) let endIndex: number; - // Check if we're inside an XML attribute (preceded by a quote) const quoteChar = leftPart.at(-1); if (quoteChar === '"' || quoteChar === "'") { endIndex = rightPart.indexOf(quoteChar); @@ -27,11 +26,6 @@ export function updateDependency({ endIndex = newlineIndex === -1 ? rightPart.length : newlineIndex; } - if (endIndex === -1) { - logger.debug({ depName }, 'Could not find end of value'); - return null; - } - const currentFound = rightPart.slice(0, endIndex); if (currentFound === newValue) { From 5ed88f6a1887ff3c62c340e117d85957a88f4e0d Mon Sep 17 00:00:00 2001 From: RahulGautamSingh Date: Thu, 9 Apr 2026 00:16:13 +0545 Subject: [PATCH 04/11] apply suggestions --- lib/modules/manager/ant/extract.spec.ts | 4 - lib/modules/manager/ant/extract.ts | 233 ++---------------------- lib/modules/manager/ant/properties.ts | 224 +++++++++++++++++++++++ 3 files changed, 237 insertions(+), 224 deletions(-) create mode 100644 lib/modules/manager/ant/properties.ts diff --git a/lib/modules/manager/ant/extract.spec.ts b/lib/modules/manager/ant/extract.spec.ts index 1d7be5fd748..4e857bcf541 100644 --- a/lib/modules/manager/ant/extract.spec.ts +++ b/lib/modules/manager/ant/extract.spec.ts @@ -10,10 +10,6 @@ import type { AntProp } from './types.ts'; vi.mock('../../../util/fs/index.ts'); describe('modules/manager/ant/extract', () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - describe('extractPackageFile', () => { it('extracts inline version dependencies from build.xml', () => { expect( diff --git a/lib/modules/manager/ant/extract.ts b/lib/modules/manager/ant/extract.ts index 733aede3f93..70af0bb74b0 100644 --- a/lib/modules/manager/ant/extract.ts +++ b/lib/modules/manager/ant/extract.ts @@ -3,7 +3,6 @@ import type { XmlElement } from 'xmldoc'; import { XmlDocument } from 'xmldoc'; import { logger } from '../../../logger/index.ts'; import { readLocalFile } from '../../../util/fs/index.ts'; -import { regEx } from '../../../util/regex.ts'; import { MavenDatasource } from '../../datasource/maven/index.ts'; import { isXmlElement } from '../nuget/util.ts'; import type { @@ -12,8 +11,18 @@ import type { PackageFile, PackageFileContent, } from '../types.ts'; +import { + applyProps, + collectProperties, + collectPropertyFileRefs, + findAttrValuePosition, + parsePropertiesFile, + resolveChainedProps, +} from './properties.ts'; import type { AntProp } from './types.ts'; +export { parsePropertiesFile } from './properties.ts'; + const scopeNames = new Set([ 'compile', 'runtime', @@ -22,9 +31,6 @@ const scopeNames = new Set([ 'system', ]); -const placeholderRegex = regEx(/\$\{([^}]+)}/g); -const fullPlaceholderRegex = regEx(/^\$\{([^}]+)}$/); - function getDependencyType(scope: string | undefined): string { if (scope && scopeNames.has(scope)) { return scope; @@ -32,125 +38,11 @@ function getDependencyType(scope: string | undefined): string { return 'compile'; } -const placeholderTestRegex = regEx(/\$\{[^}]+}/); -const propertySeparatorRegex = regEx(/^([^=:\s]+)\s*[=:\s]\s*(.*)$/); - -function containsPlaceholder(str: string | null | undefined): boolean { - return !!str && placeholderTestRegex.test(str); -} - -/** - * Find the byte offset of an attribute's value in raw XML content. - * Returns the offset of the first character of the value (after the opening quote). - */ -function findAttrValuePosition( - content: string, - node: XmlElement, - attrName: string, -): number { - const startTag = node.startTagPosition!; - const tagEnd = content.indexOf('>', startTag); - const tagContent = content.slice(startTag, tagEnd + 1); - - const attrPattern = regEx(`${attrName}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`); - const match = attrPattern.exec(tagContent)!; - - const valueInMatch = match[1] ?? match[2]; - const valueOffset = match[0].indexOf(valueInMatch); - return startTag + match.index + valueOffset; -} - -/** - * Parse a .properties file into a map of property names to AntProp. - * Implements first-definition-wins: if a key already exists in the map, it is not overwritten. - */ -export function parsePropertiesFile( - content: string, - packageFile: string, - props: Record, -): void { - let offset = 0; - for (const rawLine of content.split('\n')) { - const line = rawLine.trim(); - // Skip comments and blank lines - if (line.startsWith('#') || line.startsWith('!') || line === '') { - offset += rawLine.length + 1; // +1 for newline - continue; - } - - // Match key=value, key:value, or key value (first separator wins) - const separatorMatch = propertySeparatorRegex.exec(line); - if (separatorMatch) { - const key = separatorMatch[1]; - const val = separatorMatch[2].trim(); - - // First-definition-wins - if (!(key in props)) { - // fileReplacePosition points to the start of the value in the raw content - const lineStart = offset + rawLine.indexOf(line); - const keyEnd = line.indexOf(separatorMatch[2]); - const fileReplacePosition = lineStart + keyEnd; - - props[key] = { val, fileReplacePosition, packageFile }; - } - } - - offset += rawLine.length + 1; - } -} - interface RawDep { dep: PackageDependency; depPackageFile: string; } -/** - * Collect inline elements. - * Implements first-definition-wins. - */ -function collectProperties( - node: XmlElement | XmlDocument, - content: string, - packageFile: string, - props: Record, -): void { - for (const child of node.children) { - if (!isXmlElement(child)) { - continue; - } - - if (child.name === 'property') { - const name = child.attr.name; - const value = child.attr.value; - if (name && value && !(name in props)) { - const pos = findAttrValuePosition(content, child, 'value'); - props[name] = { val: value, fileReplacePosition: pos, packageFile }; - } - } - - collectProperties(child, content, packageFile, props); - } -} - -/** - * Collect references to external properties files. - */ -function collectPropertyFileRefs(node: XmlElement | XmlDocument): string[] { - const files: string[] = []; - for (const child of node.children) { - if (!isXmlElement(child)) { - continue; - } - - if (child.name === 'property' && child.attr.file) { - files.push(child.attr.file); - } - - files.push(...collectPropertyFileRefs(child)); - } - return files; -} - function collectDependency( node: XmlElement, packageFile: string, @@ -197,54 +89,6 @@ function walkNode( } } -/** - * Apply property resolution to a dependency. - * Handles chained references with circular detection. - */ -function applyProps( - rawDep: RawDep, - props: Record, -): PackageDependency { - const { dep, depPackageFile } = rawDep; - const currentValue = dep.currentValue; - - if (!currentValue || !containsPlaceholder(currentValue)) { - return dep; - } - - // Check if the entire version is a single property reference - const fullMatch = fullPlaceholderRegex.exec(currentValue); - if (!fullMatch) { - // Partial placeholder in version string - not supported for updates - dep.skipReason = 'version-placeholder'; - return dep; - } - - const propKey = fullMatch[1]; - const prop = props[propKey]; - if (!prop) { - dep.skipReason = 'version-placeholder'; - return dep; - } - - // After resolveChainedProps, prop.val is either fully resolved or still contains - // placeholders (meaning circular or unresolvable) - if (containsPlaceholder(prop.val)) { - dep.skipReason = 'recursive-placeholder'; - return dep; - } - - dep.currentValue = prop.val; - dep.sharedVariableName = propKey; - dep.fileReplacePosition = prop.fileReplacePosition; - if (prop.packageFile !== depPackageFile) { - dep.editFile = prop.packageFile; - } - // propSource is used to route deps to the correct PackageFile - dep.propSource = prop.packageFile; - return dep; -} - export function extractPackageFile( content: string, packageFile: string, @@ -341,7 +185,9 @@ export async function extractAllPackageFiles( resolveChainedProps(allProps); // Apply property resolution to all dependencies - const resolvedDeps = allRawDeps.map((rawDep) => applyProps(rawDep, allProps)); + const resolvedDeps = allRawDeps.map((rawDep) => + applyProps(rawDep.dep, rawDep.depPackageFile, allProps), + ); if (resolvedDeps.length === 0) { return null; @@ -369,56 +215,3 @@ export async function extractAllPackageFiles( return results; } - -/** - * Resolve chained property references within the property map itself. - * E.g., if prop A = "${B}" and prop B = "1.0", resolve A to "1.0". - * Marks circular properties by setting val to a placeholder that will be caught later. - */ -function resolveChainedProps(props: Record): void { - const resolved = new Map(); // null = circular - - function resolve(key: string, chain: Set): string | null { - if (resolved.has(key)) { - return resolved.get(key)!; - } - if (chain.has(key)) { - // Circular reference detected - resolved.set(key, null); - return null; - } - const prop = props[key]; - if (!prop) { - return null; - } - if (!containsPlaceholder(prop.val)) { - resolved.set(key, prop.val); - return prop.val; - } - - chain.add(key); - let isCircular = false; - const val = prop.val.replace(placeholderRegex, (match, refKey: string) => { - const refResult = resolve(refKey, chain); - if (refResult === null) { - isCircular = true; - return match; - } - return refResult; - }); - chain.delete(key); - - if (isCircular) { - resolved.set(key, null); - return null; - } - - resolved.set(key, val); - prop.val = val; - return val; - } - - for (const key of Object.keys(props)) { - resolve(key, new Set()); - } -} diff --git a/lib/modules/manager/ant/properties.ts b/lib/modules/manager/ant/properties.ts new file mode 100644 index 00000000000..664c3409136 --- /dev/null +++ b/lib/modules/manager/ant/properties.ts @@ -0,0 +1,224 @@ +import type { XmlDocument, XmlElement } from 'xmldoc'; +import { regEx } from '../../../util/regex.ts'; +import { isXmlElement } from '../nuget/util.ts'; +import type { PackageDependency } from '../types.ts'; +import type { AntProp } from './types.ts'; + +const placeholderRegex = regEx(/\$\{([^}]+)}/g); +const fullPlaceholderRegex = regEx(/^\$\{([^}]+)}$/); +const placeholderTestRegex = regEx(/\$\{[^}]+}/); +const propertySeparatorRegex = regEx(/^([^=:\s]+)\s*[=:\s]\s*(.*)$/); + +export function containsPlaceholder(str: string | null | undefined): boolean { + return !!str && placeholderTestRegex.test(str); +} + +/** + * Find the byte offset of an attribute's value in raw XML content. + * Returns the offset of the first character of the value (after the opening quote). + */ +export function findAttrValuePosition( + content: string, + node: XmlElement, + attrName: string, +): number { + const startTag = node.startTagPosition!; + const tagEnd = content.indexOf('>', startTag); + const tagContent = content.slice(startTag, tagEnd + 1); + + const attrPattern = regEx(`${attrName}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`); + const match = attrPattern.exec(tagContent)!; + + const valueInMatch = match[1] ?? match[2]; + const valueOffset = match[0].indexOf(valueInMatch); + return startTag + match.index + valueOffset; +} + +/** + * Parse a .properties file into a map of property names to AntProp. + * Implements first-definition-wins: if a key already exists in the map, it is not overwritten. + */ +export function parsePropertiesFile( + content: string, + packageFile: string, + props: Record, +): void { + let offset = 0; + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + // Skip comments and blank lines + if (line.startsWith('#') || line.startsWith('!') || line === '') { + offset += rawLine.length + 1; // +1 for newline + continue; + } + + // Match key=value, key:value, or key value (first separator wins) + const separatorMatch = propertySeparatorRegex.exec(line); + if (separatorMatch) { + const key = separatorMatch[1]; + const val = separatorMatch[2].trim(); + + // First-definition-wins + if (!(key in props)) { + // fileReplacePosition points to the start of the value in the raw content + const lineStart = offset + rawLine.indexOf(line); + const keyEnd = line.indexOf(separatorMatch[2]); + const fileReplacePosition = lineStart + keyEnd; + + props[key] = { val, fileReplacePosition, packageFile }; + } + } + + offset += rawLine.length + 1; + } +} + +/** + * Collect inline elements. + * Implements first-definition-wins. + */ +export function collectProperties( + node: XmlElement | XmlDocument, + content: string, + packageFile: string, + props: Record, +): void { + for (const child of node.children) { + if (!isXmlElement(child)) { + continue; + } + + if (child.name === 'property') { + const name = child.attr.name; + const value = child.attr.value; + if (name && value && !(name in props)) { + const pos = findAttrValuePosition(content, child, 'value'); + props[name] = { val: value, fileReplacePosition: pos, packageFile }; + } + } + + collectProperties(child, content, packageFile, props); + } +} + +/** + * Collect references to external properties files. + */ +export function collectPropertyFileRefs( + node: XmlElement | XmlDocument, +): string[] { + const files: string[] = []; + for (const child of node.children) { + if (!isXmlElement(child)) { + continue; + } + + if (child.name === 'property' && child.attr.file) { + files.push(child.attr.file); + } + + files.push(...collectPropertyFileRefs(child)); + } + return files; +} + +/** + * Apply property resolution to a dependency. + * Handles chained references with circular detection. + */ +export function applyProps( + dep: PackageDependency, + depPackageFile: string, + props: Record, +): PackageDependency { + const currentValue = dep.currentValue; + + if (!currentValue || !containsPlaceholder(currentValue)) { + return dep; + } + + // Check if the entire version is a single property reference + const fullMatch = fullPlaceholderRegex.exec(currentValue); + if (!fullMatch) { + // Partial placeholder in version string - not supported for updates + dep.skipReason = 'version-placeholder'; + return dep; + } + + const propKey = fullMatch[1]; + const prop = props[propKey]; + if (!prop) { + dep.skipReason = 'version-placeholder'; + return dep; + } + + // After resolveChainedProps, prop.val is either fully resolved or still contains + // placeholders (meaning circular or unresolvable) + if (containsPlaceholder(prop.val)) { + dep.skipReason = 'recursive-placeholder'; + return dep; + } + + dep.currentValue = prop.val; + dep.sharedVariableName = propKey; + dep.fileReplacePosition = prop.fileReplacePosition; + if (prop.packageFile !== depPackageFile) { + dep.editFile = prop.packageFile; + } + // propSource is used to route deps to the correct PackageFile + dep.propSource = prop.packageFile; + return dep; +} + +/** + * Resolve chained property references within the property map itself. + * E.g., if prop A = "${B}" and prop B = "1.0", resolve A to "1.0". + * Marks circular properties by setting val to a placeholder that will be caught later. + */ +export function resolveChainedProps(props: Record): void { + const resolved = new Map(); // null = circular + + function resolve(key: string, chain: Set): string | null { + if (resolved.has(key)) { + return resolved.get(key)!; + } + if (chain.has(key)) { + // Circular reference detected + resolved.set(key, null); + return null; + } + const prop = props[key]; + if (!prop) { + return null; + } + if (!containsPlaceholder(prop.val)) { + resolved.set(key, prop.val); + return prop.val; + } + + chain.add(key); + let isCircular = false; + const val = prop.val.replace(placeholderRegex, (match, refKey: string) => { + const refResult = resolve(refKey, chain); + if (refResult === null) { + isCircular = true; + return match; + } + return refResult; + }); + chain.delete(key); + + if (isCircular) { + resolved.set(key, null); + return null; + } + + resolved.set(key, val); + prop.val = val; + return val; + } + + for (const key of Object.keys(props)) { + resolve(key, new Set()); + } +} From c166cd37fa2621a3f649ca79c434251ac262e81b Mon Sep 17 00:00:00 2001 From: RahulGautamSingh Date: Fri, 10 Apr 2026 11:13:12 +0545 Subject: [PATCH 05/11] apply suggestions --- lib/modules/manager/ant/extract.spec.ts | 123 ++++----------------- lib/modules/manager/ant/properties.spec.ts | 78 +++++++++++++ lib/modules/manager/ant/properties.ts | 89 ++++++++------- 3 files changed, 150 insertions(+), 140 deletions(-) create mode 100644 lib/modules/manager/ant/properties.spec.ts diff --git a/lib/modules/manager/ant/extract.spec.ts b/lib/modules/manager/ant/extract.spec.ts index 4e857bcf541..90c23802ac8 100644 --- a/lib/modules/manager/ant/extract.spec.ts +++ b/lib/modules/manager/ant/extract.spec.ts @@ -1,11 +1,6 @@ import { codeBlock } from 'common-tags'; import { fs } from '~test/util.ts'; -import { - extractAllPackageFiles, - extractPackageFile, - parsePropertiesFile, -} from './extract.ts'; -import type { AntProp } from './types.ts'; +import { extractAllPackageFiles, extractPackageFile } from './extract.ts'; vi.mock('../../../util/fs/index.ts'); @@ -175,7 +170,7 @@ describe('modules/manager/ant/extract', () => { - + `); @@ -203,7 +198,7 @@ describe('modules/manager/ant/extract', () => { - + `); @@ -237,7 +232,7 @@ describe('modules/manager/ant/extract', () => { - + `); @@ -265,7 +260,7 @@ describe('modules/manager/ant/extract', () => { - + `); @@ -295,7 +290,7 @@ describe('modules/manager/ant/extract', () => { fs.readLocalFile.mockResolvedValue(codeBlock` - + `); @@ -318,10 +313,10 @@ describe('modules/manager/ant/extract', () => { it('detects circular property references', async () => { fs.readLocalFile.mockResolvedValue(codeBlock` - - + + - + `); @@ -345,10 +340,10 @@ describe('modules/manager/ant/extract', () => { fs.readLocalFile.mockResolvedValue(codeBlock` - - + + - + `); @@ -376,8 +371,8 @@ describe('modules/manager/ant/extract', () => { - - + + `); @@ -410,7 +405,7 @@ describe('modules/manager/ant/extract', () => { - + `); @@ -444,7 +439,7 @@ describe('modules/manager/ant/extract', () => { - + `); @@ -472,7 +467,7 @@ describe('modules/manager/ant/extract', () => { - + @@ -503,7 +498,7 @@ describe('modules/manager/ant/extract', () => { - + `); @@ -540,7 +535,7 @@ describe('modules/manager/ant/extract', () => { - + `); @@ -576,7 +571,7 @@ describe('modules/manager/ant/extract', () => { - + `); @@ -607,9 +602,9 @@ describe('modules/manager/ant/extract', () => { it('handles chain referencing undefined property', async () => { fs.readLocalFile.mockResolvedValue(codeBlock` - + - + `); @@ -629,78 +624,4 @@ describe('modules/manager/ant/extract', () => { ]); }); }); - - describe('parsePropertiesFile', () => { - it('parses key=value pairs', () => { - const props: Record = {}; - parsePropertiesFile( - 'key1=value1\nkey2=value2\n', - 'test.properties', - props, - ); - - expect(props.key1).toEqual( - expect.objectContaining({ - val: 'value1', - packageFile: 'test.properties', - }), - ); - expect(props.key2).toEqual( - expect.objectContaining({ - val: 'value2', - packageFile: 'test.properties', - }), - ); - }); - - it('skips comments and blank lines', () => { - const props: Record = {}; - parsePropertiesFile( - '# comment\n\nkey=value\n! another comment\n', - 'test.properties', - props, - ); - - expect(Object.keys(props)).toEqual(['key']); - }); - - it('supports colon separator', () => { - const props: Record = {}; - parsePropertiesFile('key:value\n', 'test.properties', props); - - expect(props.key).toEqual(expect.objectContaining({ val: 'value' })); - }); - - it('skips malformed lines without separators', () => { - const props: Record = {}; - parsePropertiesFile( - 'key=value\nmalformed_line_no_separator\nother=val\n', - 'test.properties', - props, - ); - - expect(Object.keys(props)).toEqual(['key', 'other']); - }); - - it('implements first-definition-wins', () => { - const props: Record = {}; - parsePropertiesFile('key=first\nkey=second\n', 'test.properties', props); - - expect(props.key.val).toBe('first'); - }); - - it('respects pre-existing props (first-definition-wins across sources)', () => { - const props: Record = { - key: { - val: 'existing', - fileReplacePosition: 0, - packageFile: 'build.xml', - }, - }; - parsePropertiesFile('key=new\n', 'test.properties', props); - - expect(props.key.val).toBe('existing'); - expect(props.key.packageFile).toBe('build.xml'); - }); - }); }); diff --git a/lib/modules/manager/ant/properties.spec.ts b/lib/modules/manager/ant/properties.spec.ts new file mode 100644 index 00000000000..dadc6e5973f --- /dev/null +++ b/lib/modules/manager/ant/properties.spec.ts @@ -0,0 +1,78 @@ +import { parsePropertiesFile } from './properties.ts'; +import type { AntProp } from './types.ts'; + +describe('modules/manager/ant/properties', () => { + describe('parsePropertiesFile', () => { + it('parses key=value pairs', () => { + const props: Record = {}; + parsePropertiesFile( + 'key1=value1\nkey2=value2\n', + 'test.properties', + props, + ); + + expect(props.key1).toEqual( + expect.objectContaining({ + val: 'value1', + packageFile: 'test.properties', + }), + ); + expect(props.key2).toEqual( + expect.objectContaining({ + val: 'value2', + packageFile: 'test.properties', + }), + ); + }); + + it('skips comments and blank lines', () => { + const props: Record = {}; + parsePropertiesFile( + '# comment\n\nkey=value\n! another comment\n', + 'test.properties', + props, + ); + + expect(Object.keys(props)).toEqual(['key']); + }); + + it('supports colon separator', () => { + const props: Record = {}; + parsePropertiesFile('key:value\n', 'test.properties', props); + + expect(props.key).toEqual(expect.objectContaining({ val: 'value' })); + }); + + it('skips malformed lines without separators', () => { + const props: Record = {}; + parsePropertiesFile( + 'key=value\nmalformed_line_no_separator\nother=val\n', + 'test.properties', + props, + ); + + expect(Object.keys(props)).toEqual(['key', 'other']); + }); + + it('implements first-definition-wins', () => { + const props: Record = {}; + parsePropertiesFile('key=first\nkey=second\n', 'test.properties', props); + + expect(props.key.val).toBe('first'); + }); + + it('respects pre-existing props (first-definition-wins across sources)', () => { + const props: Record = { + key: { + val: 'existing', + fileReplacePosition: 0, + packageFile: 'build.xml', + }, + }; + parsePropertiesFile('key=new\n', 'test.properties', props); + + expect(props.key.val).toBe('existing'); + expect(props.key.packageFile).toBe('build.xml'); + }); + }); +}); diff --git a/lib/modules/manager/ant/properties.ts b/lib/modules/manager/ant/properties.ts index 664c3409136..2439b15d01c 100644 --- a/lib/modules/manager/ant/properties.ts +++ b/lib/modules/manager/ant/properties.ts @@ -4,7 +4,6 @@ import { isXmlElement } from '../nuget/util.ts'; import type { PackageDependency } from '../types.ts'; import type { AntProp } from './types.ts'; -const placeholderRegex = regEx(/\$\{([^}]+)}/g); const fullPlaceholderRegex = regEx(/^\$\{([^}]+)}$/); const placeholderTestRegex = regEx(/\$\{[^}]+}/); const propertySeparatorRegex = regEx(/^([^=:\s]+)\s*[=:\s]\s*(.*)$/); @@ -171,54 +170,66 @@ export function applyProps( } /** - * Resolve chained property references within the property map itself. - * E.g., if prop A = "${B}" and prop B = "1.0", resolve A to "1.0". - * Marks circular properties by setting val to a placeholder that will be caught later. + * Resolve a single property key, following chained references. + * Returns the resolved value or null if circular/unresolvable. */ -export function resolveChainedProps(props: Record): void { - const resolved = new Map(); // null = circular - - function resolve(key: string, chain: Set): string | null { - if (resolved.has(key)) { - return resolved.get(key)!; - } - if (chain.has(key)) { - // Circular reference detected - resolved.set(key, null); - return null; - } - const prop = props[key]; - if (!prop) { - return null; - } - if (!containsPlaceholder(prop.val)) { - resolved.set(key, prop.val); - return prop.val; - } +function resolveKey( + key: string, + props: Record, + resolved: Map, + chain: Set, +): string | null { + if (resolved.has(key)) { + return resolved.get(key)!; + } + if (chain.has(key)) { + // Circular reference detected + resolved.set(key, null); + return null; + } + const prop = props[key]; + if (!prop) { + return null; + } + if (!containsPlaceholder(prop.val)) { + resolved.set(key, prop.val); + return prop.val; + } - chain.add(key); - let isCircular = false; - const val = prop.val.replace(placeholderRegex, (match, refKey: string) => { - const refResult = resolve(refKey, chain); + chain.add(key); + let isCircular = false; + const val = prop.val.replace( + regEx(/\$\{([^}]+)}/g), + (match, refKey: string) => { + const refResult = resolveKey(refKey, props, resolved, chain); if (refResult === null) { isCircular = true; return match; } return refResult; - }); - chain.delete(key); - - if (isCircular) { - resolved.set(key, null); - return null; - } + }, + ); + chain.delete(key); - resolved.set(key, val); - prop.val = val; - return val; + if (isCircular) { + resolved.set(key, null); + return null; } + resolved.set(key, val); + prop.val = val; + return val; +} + +/** + * Resolve chained property references within the property map itself. + * E.g., if prop A = "${B}" and prop B = "1.0", resolve A to "1.0". + * Marks circular properties by setting val to a placeholder that will be caught later. + */ +export function resolveChainedProps(props: Record): void { + const resolved = new Map(); // null = circular + for (const key of Object.keys(props)) { - resolve(key, new Set()); + resolveKey(key, props, resolved, new Set()); } } From 3bf5355d5cc3e32c53c560538367c8d637916e43 Mon Sep 17 00:00:00 2001 From: RahulGautamSingh Date: Mon, 13 Apr 2026 16:52:06 +0530 Subject: [PATCH 06/11] add comment --- lib/modules/manager/ant/update.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/modules/manager/ant/update.ts b/lib/modules/manager/ant/update.ts index fd1f1b7fc98..0bbe4475838 100644 --- a/lib/modules/manager/ant/update.ts +++ b/lib/modules/manager/ant/update.ts @@ -1,6 +1,8 @@ import { logger } from '../../../logger/index.ts'; import type { UpdateDependencyConfig } from '../types.ts'; +// For external .properties files: updateDependency is necessary +// because extractPackageFile can't reconstruct dep metadata from a .properties file alone export function updateDependency({ fileContent, upgrade, From 6f7efbe227987049c276e61d62aaebe8df2c8ffc Mon Sep 17 00:00:00 2001 From: RahulGautamSingh Date: Mon, 13 Apr 2026 18:15:31 +0530 Subject: [PATCH 07/11] Update lib/modules/manager/ant/update.ts Co-authored-by: Jamie Tanna --- lib/modules/manager/ant/update.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/modules/manager/ant/update.ts b/lib/modules/manager/ant/update.ts index 0bbe4475838..c359810c72e 100644 --- a/lib/modules/manager/ant/update.ts +++ b/lib/modules/manager/ant/update.ts @@ -1,8 +1,7 @@ import { logger } from '../../../logger/index.ts'; import type { UpdateDependencyConfig } from '../types.ts'; -// For external .properties files: updateDependency is necessary -// because extractPackageFile can't reconstruct dep metadata from a .properties file alone +/** For external .properties files: updateDependency is necessary because extractPackageFile can't reconstruct dep metadata from a .properties file alone */ export function updateDependency({ fileContent, upgrade, From 56c84ef0905bc6e7b95f4662e08de22ee8c85eaa Mon Sep 17 00:00:00 2001 From: RahulGautamSingh Date: Mon, 13 Apr 2026 22:05:31 +0530 Subject: [PATCH 08/11] apply suggestions zharinov --- lib/modules/manager/ant/extract.ts | 169 +++++++++++++++++++---------- 1 file changed, 109 insertions(+), 60 deletions(-) diff --git a/lib/modules/manager/ant/extract.ts b/lib/modules/manager/ant/extract.ts index 70af0bb74b0..d3df43b4ea0 100644 --- a/lib/modules/manager/ant/extract.ts +++ b/lib/modules/manager/ant/extract.ts @@ -13,8 +13,6 @@ import type { } from '../types.ts'; import { applyProps, - collectProperties, - collectPropertyFileRefs, findAttrValuePosition, parsePropertiesFile, resolveChainedProps, @@ -113,6 +111,71 @@ export function extractPackageFile( return { deps }; } +/** + * Walk an XML node tree in document order, processing properties, + * property file references, and dependencies as they appear. + */ +async function walkNodeInOrder( + node: XmlElement | XmlDocument, + packageFile: string, + content: string, + visitedFiles: Set, + allProps: Record, + allRawDeps: RawDep[], +): Promise { + const baseDir = upath.dirname(packageFile); + + for (const child of node.children) { + if (!isXmlElement(child)) { + continue; + } + + if (child.name === 'property') { + // Handle inline property definition + const name = child.attr.name; + const value = child.attr.value; + if (name && value && !(name in allProps)) { + const pos = findAttrValuePosition(content, child, 'value'); + allProps[name] = { val: value, fileReplacePosition: pos, packageFile }; + } + + // Handle property file reference + const file = child.attr.file; + if (file) { + const propFilePath = file.startsWith('/') + ? file + : upath.join(baseDir, file); + + if (!visitedFiles.has(propFilePath)) { + visitedFiles.add(propFilePath); + const propContent = await readLocalFile(propFilePath, 'utf8'); + if (propContent) { + parsePropertiesFile(propContent, propFilePath, allProps); + } else { + logger.debug( + `ant manager: could not read properties file ${propFilePath}`, + ); + } + } + } + } else if (child.name === 'dependency') { + const rawDep = collectDependency(child, packageFile, content); + if (rawDep) { + allRawDeps.push(rawDep); + } + } else { + await walkNodeInOrder( + child, + packageFile, + content, + visitedFiles, + allProps, + allRawDeps, + ); + } + } +} + async function walkXmlFile( packageFile: string, visitedFiles: Set, @@ -138,80 +201,66 @@ async function walkXmlFile( return; } - // Collect property file references first (order matters for first-definition-wins) - const propertyFileRefs = collectPropertyFileRefs(doc); - - // Collect inline properties (first-definition-wins: inline before file refs) - collectProperties(doc, content, packageFile, allProps); - - // Load external .properties files - const baseDir = upath.dirname(packageFile); - for (const ref of propertyFileRefs) { - const propFilePath = ref.startsWith('/') ? ref : upath.join(baseDir, ref); - - if (visitedFiles.has(propFilePath)) { - continue; - } - visitedFiles.add(propFilePath); - - const propContent = await readLocalFile(propFilePath, 'utf8'); - if (!propContent) { - logger.debug( - `ant manager: could not read properties file ${propFilePath}`, - ); - continue; - } - - parsePropertiesFile(propContent, propFilePath, allProps); - } - - // Collect dependencies - walkNode(doc, allRawDeps, packageFile, content); + await walkNodeInOrder( + doc, + packageFile, + content, + visitedFiles, + allProps, + allRawDeps, + ); } export async function extractAllPackageFiles( _config: ExtractConfig, packageFiles: string[], ): Promise { - const visitedFiles = new Set(); - const allProps: Record = {}; - const allRawDeps: RawDep[] = []; + const results: PackageFile[] = []; + const seen = new Set(); for (const packageFile of packageFiles) { + if (seen.has(packageFile)) { + continue; + } + seen.add(packageFile); + + const visitedFiles = new Set(); + const allProps: Record = {}; + const allRawDeps: RawDep[] = []; + await walkXmlFile(packageFile, visitedFiles, allProps, allRawDeps); - } - // Resolve chained property values before applying to deps - resolveChainedProps(allProps); + // Resolve chained property values before applying to deps + resolveChainedProps(allProps); - // Apply property resolution to all dependencies - const resolvedDeps = allRawDeps.map((rawDep) => - applyProps(rawDep.dep, rawDep.depPackageFile, allProps), - ); + // Apply property resolution to all dependencies + const resolvedDeps = allRawDeps.map((rawDep) => + applyProps(rawDep.dep, rawDep.depPackageFile, allProps), + ); - if (resolvedDeps.length === 0) { - return null; - } + if (resolvedDeps.length === 0) { + continue; + } - // Group deps by their target file (propSource or original packageFile) - const fileMap = new Map(); - for (let i = 0; i < resolvedDeps.length; i++) { - const dep = resolvedDeps[i]; - const targetFile = dep.propSource ?? allRawDeps[i].depPackageFile; - if (!fileMap.has(targetFile)) { - fileMap.set(targetFile, []); + // Group deps by their target file (propSource or original packageFile) + const fileMap = new Map(); + for (let i = 0; i < resolvedDeps.length; i++) { + const dep = resolvedDeps[i]; + const targetFile = dep.propSource ?? allRawDeps[i].depPackageFile; + if (!fileMap.has(targetFile)) { + fileMap.set(targetFile, []); + } + fileMap.get(targetFile)!.push(dep); } - fileMap.get(targetFile)!.push(dep); - } - const results: PackageFile[] = []; - for (const [packageFile, deps] of fileMap) { - // Clean up internal propSource field - for (const dep of deps) { - delete dep.propSource; + for (const [pkgFile, deps] of fileMap) { + // Clean up internal propSource field + for (const dep of deps) { + delete dep.propSource; + } + results.push({ packageFile: pkgFile, deps }); } - results.push({ packageFile, deps }); } - return results; + return results.length > 0 ? results : null; } From d91205c7db52852e49e021bcd3556a9764183863 Mon Sep 17 00:00:00 2001 From: RahulGautamSingh Date: Mon, 13 Apr 2026 23:51:24 +0530 Subject: [PATCH 09/11] fix coverage --- lib/modules/manager/ant/extract.spec.ts | 27 +++++++++++++++++++++++++ lib/modules/manager/ant/extract.ts | 3 --- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/lib/modules/manager/ant/extract.spec.ts b/lib/modules/manager/ant/extract.spec.ts index 90c23802ac8..fe36d2e35b8 100644 --- a/lib/modules/manager/ant/extract.spec.ts +++ b/lib/modules/manager/ant/extract.spec.ts @@ -493,6 +493,33 @@ describe('modules/manager/ant/extract', () => { ]); }); + it('ignores dependency without version during property resolution', async () => { + fs.readLocalFile.mockResolvedValue(codeBlock` + + + + + + + + `); + + const result = await extractAllPackageFiles({}, ['build.xml']); + + expect(result).toEqual([ + { + packageFile: 'build.xml', + deps: [ + expect.objectContaining({ + depName: 'junit:junit', + currentValue: '4.13.2', + sharedVariableName: 'junit.version', + }), + ], + }, + ]); + }); + it('skips partial placeholder in version string', async () => { fs.readLocalFile.mockResolvedValue(codeBlock` diff --git a/lib/modules/manager/ant/extract.ts b/lib/modules/manager/ant/extract.ts index d3df43b4ea0..529f4636699 100644 --- a/lib/modules/manager/ant/extract.ts +++ b/lib/modules/manager/ant/extract.ts @@ -182,9 +182,6 @@ async function walkXmlFile( allProps: Record, allRawDeps: RawDep[], ): Promise { - if (visitedFiles.has(packageFile)) { - return; - } visitedFiles.add(packageFile); const content = await readLocalFile(packageFile, 'utf8'); From b20d6978f422f1d3d5623fb40d5886540ee7937b Mon Sep 17 00:00:00 2001 From: RahulGautamSingh Date: Tue, 14 Apr 2026 11:06:46 +0530 Subject: [PATCH 10/11] fix coverage --- lib/modules/manager/ant/properties.ts | 52 +-------------------------- 1 file changed, 1 insertion(+), 51 deletions(-) diff --git a/lib/modules/manager/ant/properties.ts b/lib/modules/manager/ant/properties.ts index 2439b15d01c..0bd24b261be 100644 --- a/lib/modules/manager/ant/properties.ts +++ b/lib/modules/manager/ant/properties.ts @@ -1,6 +1,5 @@ -import type { XmlDocument, XmlElement } from 'xmldoc'; +import type { XmlElement } from 'xmldoc'; import { regEx } from '../../../util/regex.ts'; -import { isXmlElement } from '../nuget/util.ts'; import type { PackageDependency } from '../types.ts'; import type { AntProp } from './types.ts'; @@ -72,55 +71,6 @@ export function parsePropertiesFile( } } -/** - * Collect inline elements. - * Implements first-definition-wins. - */ -export function collectProperties( - node: XmlElement | XmlDocument, - content: string, - packageFile: string, - props: Record, -): void { - for (const child of node.children) { - if (!isXmlElement(child)) { - continue; - } - - if (child.name === 'property') { - const name = child.attr.name; - const value = child.attr.value; - if (name && value && !(name in props)) { - const pos = findAttrValuePosition(content, child, 'value'); - props[name] = { val: value, fileReplacePosition: pos, packageFile }; - } - } - - collectProperties(child, content, packageFile, props); - } -} - -/** - * Collect references to external properties files. - */ -export function collectPropertyFileRefs( - node: XmlElement | XmlDocument, -): string[] { - const files: string[] = []; - for (const child of node.children) { - if (!isXmlElement(child)) { - continue; - } - - if (child.name === 'property' && child.attr.file) { - files.push(child.attr.file); - } - - files.push(...collectPropertyFileRefs(child)); - } - return files; -} - /** * Apply property resolution to a dependency. * Handles chained references with circular detection. From b76b0ad151f4da55d8b4fbc9c9be50e4bb1362e2 Mon Sep 17 00:00:00 2001 From: RahulGautamSingh Date: Tue, 14 Apr 2026 19:32:29 +0530 Subject: [PATCH 11/11] escape attrName --- lib/modules/manager/ant/properties.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/modules/manager/ant/properties.ts b/lib/modules/manager/ant/properties.ts index 0bd24b261be..039bdc7000b 100644 --- a/lib/modules/manager/ant/properties.ts +++ b/lib/modules/manager/ant/properties.ts @@ -1,5 +1,5 @@ import type { XmlElement } from 'xmldoc'; -import { regEx } from '../../../util/regex.ts'; +import { escapeRegExp, regEx } from '../../../util/regex.ts'; import type { PackageDependency } from '../types.ts'; import type { AntProp } from './types.ts'; @@ -24,7 +24,9 @@ export function findAttrValuePosition( const tagEnd = content.indexOf('>', startTag); const tagContent = content.slice(startTag, tagEnd + 1); - const attrPattern = regEx(`${attrName}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`); + const attrPattern = regEx( + `${escapeRegExp(attrName)}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, + ); const match = attrPattern.exec(tagContent)!; const valueInMatch = match[1] ?? match[2];