Files
renovate/lib/modules/manager/gradle/extract/consistent-versions-plugin.ts
Sebastian Poxhofer a1971afa78 fix(linters): add linting rule to enforce usage of our regEx utility (#44690)
* ci(linter): add renovate/require-regex-util rule

Adds a custom oxlint rule that flags raw RegExp usage in lib/
production code: any `new RegExp(...)` call and any regex literal
that is not the direct first argument of `regEx(...)`. The regEx()
helper from lib/util/regex.ts uses the RE2 engine for ReDoS-safe
matching and caches compiled patterns. lib/util/regex.ts itself is
exempt.

The rule is disabled for **/*.spec.ts, test/**, and tools/** via
config overrides, because vitest APIs such as expect.stringMatching
need bare RegExp instances (a past commit reverted regEx() there).

Severity is set to "warn" instead of "error": the repo currently has
197 pre-existing violations (above the agreed mechanical-fix
threshold of 80), and a number of them are deliberately
RE2-incompatible patterns (lookahead/lookbehind/named backreferences,
see TODO #12872/#12875 markers in e.g. lib/modules/versioning/rez/
pattern.ts and lib/modules/manager/dockerfile/extract.ts) that need
per-site judgment before they can be wrapped in regEx() or suppressed
with a justified disable comment. No violation fixes are included in
this commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(linter): enforce renovate/require-regex-util at error

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: use regEx() for regex literals and RegExp construction in lib

Burn down all renovate/require-regex-util violations (197):

- wrap RE2-compatible regex literals and RegExp constructions in regEx()
- rewrite lookaround patterns where behavior is provably preserved:
  goproxy separator split, sbt-package link extraction, leiningen
  comment stripping and keyword search, npm GitHub owner validation
  (unit-count bound replaced by an explicit length check)
- keep native RegExp with disable comments where RE2 cannot be used:
  moo / good-enough-parser lexer patterns (moo rejects RE2 instances),
  MigrationsService propertyName (matched via isRegExp, which RE2
  instances fail), rez named backreferences and lookaheads, GitHub
  markdown link negative lookbehind, emoji astral-plane matching
- drop stale TODO #12875 comments for patterns current re2 handles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: reduce require-regex-util disable comments

Rewrite the github massage-markdown-links URL regex to capture an
optional `api.` prefix in a named group (skipped in code) instead of
relying on a negative lookbehind, so both the base and global variants
can use regEx()/RE2 and their two disable comments are removed. Add
spec cases for api-prefixed URLs that match the URL shape.

Consolidate the four related line disables in rez/pattern.ts (bound and
range patterns emulating Python conditional patterns via named
backreferences and lookaheads, which RE2 cannot express) into a single
block disable/enable pair, matching the treatment of the lexer files.

Disable comment count drops from 13 to 8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(datasource/sbt-package): use named capture group in link regex

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(manager/cake): restore original multiLineComment regex spelling

The [^] -> [\\s\\S] rewrite was only needed for an RE2 wrap that was
later reverted; keep the diff minimal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(datasource/go): skip empty GOPROXY segments

The switch from a lookahead split to a capture-group split (needed because
RE2 has no lookahead support) changed how empty GOPROXY segments parse:
`a|,b` yielded `{ url: ',', fallback: '|' }` before and
`{ url: '', fallback: ',' }` after. The comma fallback only continues on
404/410, so a failing proxy ahead of an empty segment silently stopped the
lookup instead of falling through to the remaining proxies.

Empty segments carry no url to query, so drop them.

* refactor(datasource/go): use isTruthy for empty segment filter

* fix(datasource/go): filter empty segments before mapping

Filtering after the map broke the contextual typing from the
GoproxyItem[] annotation, widening fallback to string.

* refactor(platform/github): use regEx() for merge queue query stripping

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 14:30:46 +00:00

219 lines
7.9 KiB
TypeScript

import { logger } from '../../../../logger/index.ts';
import * as fs from '../../../../util/fs/index.ts';
import { newlineRegex, regEx } from '../../../../util/regex.ts';
import { coerceString } from '../../../../util/string.ts';
import type { PackageDependency } from '../../types.ts';
import type { GradleManagerData } from '../types.ts';
import { isDependencyString, versionLikeSubstring } from '../utils.ts';
export const VERSIONS_PROPS = 'versions.props';
export const VERSIONS_LOCK = 'versions.lock';
export const LOCKFIlE_HEADER_TEXT = regEx(
/^# Run \.\/gradlew (?:--write-locks|writeVersionsLock|writeVersionsLocks) to regenerate this file/,
);
/**
* Determines if Palantir gradle-consistent-versions is in use, https://github.com/palantir/gradle-consistent-versions.
* Both `versions.props` and `versions.lock` must exist and the special header line of lock file must match
*
* @param versionsPropsFilename is the full file name path of `versions.props`
* @param fileContents map with file contents of all files
*/
export function usesGcv(
versionsPropsFilename: string,
fileContents: Record<string, string | null>,
): boolean {
const versionsLockFile: string = fs.getSiblingFileName(
versionsPropsFilename,
VERSIONS_LOCK,
);
return !!fileContents[versionsLockFile]?.match(LOCKFIlE_HEADER_TEXT);
}
/**
* Confirms whether the provided file name is the props file
*/
export function isGcvPropsFile(fileName: string): boolean {
return fileName === VERSIONS_PROPS || fileName.endsWith(`/${VERSIONS_PROPS}`);
}
/**
* Confirms whether the provided file name is the lock file
*/
export function isGcvLockFile(fileName: string): boolean {
return fileName === VERSIONS_LOCK || fileName.endsWith(`/${VERSIONS_LOCK}`);
}
/**
* Parses Gradle-Consistent-Versions files to figure out what dependencies, versions
* and groups they contain. The parsing goes like this:
* - Parse `versions.props` into deps (or groups) and versions, remembering file offsets
* - Parse `versions.lock` into deps and lock-versions
* - For each exact dep in props file, lookup the lock-version from lock file
* - For each group/regex dep in props file, lookup the set of exact deps and versions in lock file
*
* @param propsFileName name and path of the props file
* @param fileContents text content of all files
*/
export function parseGcv(
propsFileName: string,
fileContents: Record<string, string | null>,
): PackageDependency<GradleManagerData>[] {
const propsFileContent = coerceString(fileContents[propsFileName]);
const lockFileName = fs.getSiblingFileName(propsFileName, VERSIONS_LOCK);
const lockFileContent = coerceString(fileContents[lockFileName]);
const lockFileMap = parseLockFile(lockFileContent);
const [propsFileExactMap, propsFileRegexMap] =
parsePropsFile(propsFileContent);
const extractedDeps: PackageDependency<GradleManagerData>[] = [];
// For each exact dep in props file
for (const [propDep, versionAndPosition] of propsFileExactMap) {
if (lockFileMap.has(propDep)) {
const newDep: Record<string, any> = {
managerData: {
packageFile: propsFileName,
fileReplacePosition: versionAndPosition.filePos,
},
depName: propDep,
currentValue: versionAndPosition.version,
lockedVersion: lockFileMap.get(propDep)?.version,
depType: lockFileMap.get(propDep)?.depType,
} satisfies PackageDependency<GradleManagerData>;
extractedDeps.push(newDep);
// Remove from the lockfile map so the same exact lib will not be included in globbing
lockFileMap.delete(propDep);
}
}
// For each regular expression dep in props file (starting with the longest glob string)...
for (const [propDepGlob, propVerAndPos] of propsFileRegexMap) {
const globRegex = globToRegex(propDepGlob);
for (const [exactDep, lockVersionAndDepType] of lockFileMap) {
if (globRegex.test(exactDep)) {
const newDep: Record<string, any> = {
managerData: {
packageFile: propsFileName,
fileReplacePosition: propVerAndPos.filePos,
},
depName: exactDep,
currentValue: propVerAndPos.version,
lockedVersion: lockVersionAndDepType.version,
depType: lockVersionAndDepType.depType,
sharedVariableName: propDepGlob,
} satisfies PackageDependency<GradleManagerData>;
extractedDeps.push(newDep);
// Remove from the lockfile map so the same lib will not be included in more generic globs later
lockFileMap.delete(exactDep);
}
}
}
return extractedDeps;
}
// Translate glob syntax to a regex that does the same. Note that we cannot use replaceAll as it does not exist in Node14
// Loosely borrowed mapping to regex from https://github.com/palantir/gradle-consistent-versions/blob/develop/src/main/java/com/palantir/gradle/versions/FuzzyPatternResolver.java
function globToRegex(depName: string): RegExp {
return regEx(
depName
.replace(regEx(/\*/g), '_WC_CHAR_')
.replace(regEx(/[/\-\\^$*+?.()|[\]{}]/g), '\\$&')
.replace(regEx(/_WC_CHAR_/g), '.*?'),
);
}
interface VersionWithPosition {
version: string;
filePos: number;
}
interface VersionWithDepType {
version: string;
depType: string;
}
/**
* Parses `versions.lock`
*/
export function parseLockFile(input: string): Map<string, VersionWithDepType> {
const lockLineRegex = regEx(
`^(?<depName>[^:]+:[^:]+):(?<lockVersion>[^ ]+) \\(\\d+ constraints: [0-9a-f]+\\)$`,
);
const depVerMap = new Map<string, VersionWithDepType>();
let isTestDepType = false;
for (const line of input.split(newlineRegex)) {
const lineMatch = lockLineRegex.exec(line);
if (lineMatch?.groups) {
const { depName, lockVersion } = lineMatch.groups;
if (isDependencyString(`${depName}:${lockVersion}`)) {
depVerMap.set(depName, {
version: lockVersion,
depType: isTestDepType ? 'test' : 'dependencies',
});
}
} else if (line === '[Test dependencies]') {
isTestDepType = true; // We know that all lines below this header are test dependencies
}
}
logger.trace(
`Found ${depVerMap.size} locked dependencies in ${VERSIONS_LOCK}.`,
);
return depVerMap;
}
/**
* Parses `versions.props`, this is CR/LF safe
* @param input the entire property file from file system
* @return two maps, first being exact matches, second regex matches
*/
export function parsePropsFile(
input: string,
): [Map<string, VersionWithPosition>, Map<string, VersionWithPosition>] {
const propsLineRegex = regEx(
`^(?<depName>[^:]+:[^=]+?) *= *(?<propsVersion>.*)$`,
);
const depVerExactMap = new Map<string, VersionWithPosition>();
const depVerRegexMap = new Map<string, VersionWithPosition>();
let startOfLineIdx = 0;
const isCrLf = input.indexOf('\r\n') > 0;
const validGlob = regEx(/^[a-zA-Z][-_a-zA-Z0-9.:*]+$/);
for (const line of input.split(newlineRegex)) {
const lineMatch = propsLineRegex.exec(line);
if (lineMatch?.groups) {
const { depName, propsVersion } = lineMatch.groups;
if (
validGlob.test(depName) &&
versionLikeSubstring(propsVersion) !== null
) {
const startPosInLine = line.lastIndexOf(propsVersion);
const propVersionPos = startOfLineIdx + startPosInLine;
if (depName.includes('*')) {
depVerRegexMap.set(depName, {
version: propsVersion,
filePos: propVersionPos,
});
} else {
depVerExactMap.set(depName, {
version: propsVersion,
filePos: propVersionPos,
});
}
}
}
startOfLineIdx += line.length + (isCrLf ? 2 : 1);
}
logger.trace(
`Found ${depVerExactMap.size} dependencies and ${depVerRegexMap.size} wildcard dependencies in ${VERSIONS_PROPS}.`,
);
return [
depVerExactMap,
new Map(
[...depVerRegexMap].sort(([ka], [kb]) => ka.localeCompare(kb)).reverse(),
),
];
}