Files
Sebastian Poxhofer 3412de84ce refactor(linters): enable oxlint rule prefer-named-capture-group (#44714)
* refactor(lint): enable oxlint prefer-named-capture-group at error

Numbered capture groups (m[1], $1) break silently when a regex is
edited and group order shifts. Named groups (?<name>...) make intent
explicit and accesses (m.groups.name) resilient to reordering.

This enables the built-in eslint rule prefer-named-capture-group,
surfacing 321 pre-existing unnamed-group violations across ~121
files to be burned down incrementally in follow-up commits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk

* refactor(versioning): name capture groups for prefer-named-capture-group

Burns down all prefer-named-capture-group violations in
lib/modules/versioning/. Unused capture groups (only reachable via
.test() or whole-match) become non-capturing (?:...); groups whose
value is read become named (?<name>...), with accesses rewritten to
match.groups!.<name> (or destructured from .groups!) instead of
positional match[n]/$n so a future edit that reorders groups cannot
silently break the reader.

Two cases needed extra care to preserve behavior:
- semver/common.ts and poetry/patterns.ts split() on a regex whose
  capture group is relied upon by String.prototype.split to keep the
  separator in the output array; the group is kept capturing and
  named, not converted to non-capturing.
- hashicorp/convertor.ts's semverRegex is only ever embedded via
  .source into a named `(?<version>...)` group elsewhere and never
  matched directly, so its internal groups are safe to make
  non-capturing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk

* refactor(datasource): name capture groups for prefer-named-capture-group

Burns down all prefer-named-capture-group violations under
lib/modules/datasource/. Same treatment as the versioning module:
unused groups become non-capturing, used groups get names, and
positional match[n] reads move to match.groups!.<name>.

docker/ecr.ts and pypi/common.ts keep their positional destructuring
(`const [, region] = ...`, `!!m &&`) working unchanged since a named
capture group is still addressable by its numeric index in
JavaScript — only pypi/common.ts's access was rewritten to
m.groups!.owner for clarity since it read a single group directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk

* refactor(platform): name capture groups for prefer-named-capture-group

Burns down all prefer-named-capture-group violations under
lib/modules/platform/. Uses the repo's established idiom of
`match.groups?.<name>` after an optional-chained exec()/match() (see
bitbucket/index.ts hostnameWithoutApiPrefix, gerrit/utils.ts
extractSourceBranch) and `$<name>` in String.replace replacement
strings. Unused groups (markdown/HTML massaging regexes matched only
to be stripped) become non-capturing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk

* refactor(manager): name capture groups in ansible-galaxy for prefer-named-capture-group

Burns down prefer-named-capture-group violations in
lib/modules/manager/ansible-galaxy/. newBlockRegEx and blockLineRegEx
dropped their unused outer wrapping group (only the inner key/value
groups were ever read) and named the key/value groups; nameMatchRegex's
remaining unused groups (protocol/user-info/port alternatives) become
non-capturing since only source/hostname/depName/version are read via
.groups. Accesses in collections.ts and roles.ts move from
lineMatch[2]/lineMatch[3] to lineMatch.groups!.key/value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk

* refactor(manager): name capture groups for prefer-named-capture-group (batch 1)

Burns down prefer-named-capture-group violations across the first
alphabetical slice of lib/modules/manager/ (ansible through mix).
Used groups get names with accesses rewritten to match.groups!.<name>
(or $<name> in replacement strings); groups whose value is never read
become non-capturing (?:...). Two String.split() call sites
(git-submodules/extract.ts, meteor/extract.ts) keep the established
"named group still readable positionally from the split() result
array" idiom instead of restructuring the destructuring.

bazel/rules/go.ts and buildpacks/extract.ts dockerRef verified
byte-for-byte equivalent before/after via a quick node script since
several nested groups collapsed to non-capturing at once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk

* refactor(manager): name capture groups for prefer-named-capture-group (batch 2)

Burns down prefer-named-capture-group violations across the remaining
alphabetical slice of lib/modules/manager/ (npm through terragrunt).
Same treatment as batch 1: used groups get names with accesses moved
to match.groups!.<name> or $<name> replacement strings; unused groups
(wrapping alternations, quantifier repetition groups only consumed via
.test(), or values never read from a match) become non-capturing.

terraform/extractors/others/modules.ts and terragrunt/modules.ts share
near-identical URL-parsing regexes (github/bitbucket/git/azure-devops
ref matchers) — the same set of always-unused `depth=`/`&depth=`
query-string groups is converted to non-capturing in both files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk

* refactor(config): name capture groups for prefer-named-capture-group

Burns down prefer-named-capture-group violations in lib/config/.
Used groups get names with replacement strings switched from $1/$2 to
$<name>; the unused separator group in schedule-migration.ts's
afterBeforeRegex becomes non-capturing. presets/parse.ts keeps
assigning through the already-declared `repo` variable rather than
array-destructuring the exec() result, matching this file's existing
style for the other named-group regexes just above it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk

* refactor(util): name capture groups for prefer-named-capture-group

Burns down prefer-named-capture-group violations across lib/util/ and
lib/logger/. Same treatment as previous batches: named groups for
values that are read (via $<name> replacement strings, .groups!.<name>,
or destructured from .groups), non-capturing groups where nothing reads
the captured value (e.g. semantic.ts's angular-commit .test()-only
regex). www-authenticate.ts's tokenizer entries each get their own
named group but keep accessing the match generically via match[1],
since a positionally-indexed named group is still readable by index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk

* refactor(workers): name capture groups for prefer-named-capture-group

Burns down prefer-named-capture-group violations in lib/workers/.
Used groups get names with $<name> replacement strings; unused
wrapper/alternation groups (e.g. semantic-commit-message.ts's optional
scope/issue wrappers, release-notes.ts's date-heading and
horizontal-rule matchers that are only probed with .search()/replaced
wholesale) become non-capturing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk

* refactor(test,tools): name capture groups for prefer-named-capture-group

Finishes the prefer-named-capture-group burndown: test/, tools/, and
vitest.config.mts. Groups whose value is read get names ($<name>
replacement strings or .groups.<name> access); groups only used via
.test()/.search() or that feed a .match(/…/g) call (whose return value
is the array of whole matches, not captured groups) become
non-capturing.

This is the last remaining slice of pre-existing violations, so the
rule (already enabled at error repo-wide) now passes with zero
findings across the whole codebase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk

* fix prettier failure

* refactor(lint): use a named capture group in `v8-ignore-no-count`

New rule from main trips the newly enabled `prefer-named-capture-group` oxlint rule.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 09:54:40 +00:00

577 lines
16 KiB
TypeScript

import { gte, lt, lte, satisfies } from '@renovatebot/pep440';
import { parse as parseRange } from '@renovatebot/pep440/lib/specifier.js';
import { parse as parseVersion } from '@renovatebot/pep440/lib/version.js';
import { isTruthy } from '@sindresorhus/is';
import { logger } from '../../../logger/index.ts';
import { coerceArray } from '../../../util/array.ts';
import { regEx } from '../../../util/regex.ts';
import type { NewValueConfig } from '../types.ts';
const UserPolicyPrecisionMap = {
Major: 0,
Minor: 1,
Micro: 2,
Bug: 3,
None: Infinity,
} as const;
const PrecisionUserPolicyMap = {
0: 'Major',
1: 'Minor',
2: 'Micro',
3: 'Bug',
} as const;
type UserPolicy =
(typeof UserPolicyPrecisionMap)[keyof typeof UserPolicyPrecisionMap];
/**
* Calculate current update range precision.
* @param ranges A {@link Range} consists of current range
* @returns A {@link UserPolicy}
*/
function getRangePrecision(ranges: Range[]): UserPolicy {
const bound = coerceArray(
parseVersion((ranges[1] || ranges[0]).version)?.release,
);
let rangePrecision = -1;
// range is defined by a single bound.
// ie. <1.2.2.3,
// >=7
if (ranges.length === 1) {
rangePrecision = bound.length - 1;
}
// Range is defined by both upper and lower bounds.
if (ranges.length === 2) {
const lowerBound = coerceArray(parseVersion(ranges[0].version)?.release);
rangePrecision = bound.findIndex((el, index) => el > lowerBound[index]);
}
// Tune down Major precision if followed by a zero
if (
rangePrecision === UserPolicyPrecisionMap.Major &&
rangePrecision + 1 < bound.length &&
bound[rangePrecision + 1] === 0
) {
rangePrecision++;
}
// Could not calculate user precision
// Default to the smallest possible
// istanbul ignore next
if (rangePrecision === -1) {
rangePrecision = bound.length - 1;
}
const key =
PrecisionUserPolicyMap[
rangePrecision as keyof typeof PrecisionUserPolicyMap
];
return UserPolicyPrecisionMap[key as keyof typeof UserPolicyPrecisionMap];
}
/**
* @param policy Required range precision
* @param newVersion The newly accepted version
* @param baseVersion Optional Current upper bound
* @returns A string represents a future version upper bound.
*/
function getFutureVersion(
policy: UserPolicy,
newVersion: string,
baseVersion?: string,
): number[] {
const toRelease = coerceArray(parseVersion(newVersion)?.release);
const baseRelease = coerceArray(
parseVersion(baseVersion ?? newVersion)?.release,
);
return baseRelease.map((_, index) => {
const toPart = toRelease[index] ?? 0;
if (index < policy) {
return toPart;
}
if (index === policy) {
return toPart + (baseVersion === undefined ? 0 : 1);
}
return 0;
});
}
interface Range {
operator: string;
prefix?: string;
version: string;
}
export function getPinnedValue(newVersion: string): string {
return `==${newVersion}`;
}
export function getNewValue({
currentValue,
rangeStrategy,
currentVersion,
newVersion,
isReplacement,
}: NewValueConfig): string | null {
let ranges: Range[];
let updatedRange: (string | null)[];
// no symbol: accept only that specific version specified
if (currentValue === currentVersion || isReplacement) {
return newVersion;
}
// Handle bare versions (e.g., "v0.7.15") that don't strictly equal
// currentVersion due to normalization, treating them as pinned while
// preserving the v-prefix.
if (parseVersion(currentValue)) {
const vPrefix = regEx(/^(?<prefix>[vV])/).exec(currentValue);
if (vPrefix) {
return `${vPrefix.groups!.prefix}${newVersion}`;
}
return newVersion;
}
try {
ranges = parseCurrentRange(currentValue);
if (!ranges.length) {
// an empty string is an allowed value for PEP440 range
// it means get any version
logger.warn({ currentValue }, 'Empty currentValue');
return currentValue;
}
} catch (err) {
logger.warn({ currentValue, err }, 'Unexpected range error');
return null;
}
// return early if newVersion is excluded from range
if (
ranges.some(
(range) => range.operator === '!=' && range.version === newVersion,
)
) {
logger.debug(
`Cannot calculate new value as the newVersion:\`${newVersion}\` is excluded from range: \`${currentValue}\``,
);
return null;
}
switch (rangeStrategy) {
case 'auto':
case 'replace':
updatedRange = handleReplaceStrategy(
{
currentValue,
rangeStrategy,
currentVersion,
newVersion,
},
ranges,
);
break;
case 'widen':
updatedRange = handleWidenStrategy(
{
currentValue,
rangeStrategy,
currentVersion,
newVersion,
},
ranges,
);
break;
case 'bump':
updatedRange = handleBumpStrategy(
{
currentValue,
rangeStrategy,
currentVersion,
newVersion,
},
ranges,
);
break;
default:
// Unsupported rangeStrategy
// Valid rangeStrategy values are: bump, extend, pin, replace.
// https://docs.renovatebot.com/modules/versioning/#pep440-versioning
logger.debug(
`Unsupported rangeStrategy: ${rangeStrategy}. Using "replace" instead.`,
);
return getNewValue({
currentValue,
rangeStrategy: 'replace',
currentVersion,
newVersion,
});
}
let result = updatedRange.filter(isTruthy).join(', ');
if (result.includes(', ') && !currentValue.includes(', ')) {
result = result.replace(regEx(/, /g), ',');
}
const checkedResult = checkRangeAndRemoveUnnecessaryRangeLimit(
result,
newVersion,
);
// istanbul ignore if
if (!satisfies(newVersion, checkedResult)) {
// we failed at creating the range
logger.warn(
{ result, newVersion, currentValue },
'pep440: failed to calculate newValue',
);
return null;
}
return checkedResult;
}
export function isLessThanRange(input: string, range: string): boolean {
try {
let invertResult = true;
const results = range
.split(',')
.map((x) =>
x
.replace(regEx(/\s*/g), '')
.split(regEx(/(?<op>~=|==|!=|<=|>=|<|>|===)/))
.slice(1),
)
.map(([op, version]) => {
if (['!=', '<=', '<'].includes(op)) {
return true;
}
invertResult = false;
if (['~=', '==', '>=', '==='].includes(op)) {
return lt(input, version);
}
if (op === '>') {
return lte(input, version);
}
// istanbul ignore next
return false;
});
const result = results.every((res) => res === true);
return invertResult ? !result : result;
} catch /* istanbul ignore next */ {
return false;
}
}
function parseCurrentRange(currentValue: string): Range[] {
const ranges: Range[] = parseRange(currentValue);
if (!ranges) {
throw new TypeError('Invalid pep440 currentValue');
}
if (ranges.some((range) => range.operator === '===')) {
// the operator "===" is used for legacy non PEP440 versions
throw new TypeError('PEP440 arbitrary equality (===) not supported');
}
return ranges;
}
function handleLowerBound(range: Range, newVersion: string): string | null {
// used to mark minimum supported version
// lower the bound if the new version is lower than current range
if (['>', '>='].includes(range.operator)) {
if (lte(newVersion, range.version)) {
// this looks like a rollback
return `>=${newVersion}`;
}
// otherwise, treat it same as exclude
return range.operator + range.version;
}
// istanbul ignore next
return null;
}
function handleUpperBound(range: Range, newVersion: string): string | null {
// this is used to exclude future versions
if (range.operator === '<') {
// if newVersion is that future version
if (gte(newVersion, range.version)) {
// now here things get tricky
// we calculate the new future version
const precision = getRangePrecision([range]);
const futureVersion = getFutureVersion(
precision,
newVersion,
range.version,
);
return range.operator + futureVersion.join('.');
}
// newVersion is in range, for other than "replace" strategies
return range.operator + range.version;
}
// istanbul ignore next
return null;
}
function updateRangeValue(
{ currentValue, newVersion }: NewValueConfig,
range: Range,
): string | null {
// used to exclude versions,
// we assume that's for a good reason
if (range.operator === '!=') {
return range.operator + range.version;
}
// keep the .* suffix
if (range.prefix) {
const futureVersion = getFutureVersion(
UserPolicyPrecisionMap.None,
newVersion,
range.version,
).join('.');
return `${range.operator}${futureVersion}.*`;
}
if (range.operator === '~=') {
const baseVersion = coerceArray(parseVersion(range.version)?.release);
const futureVersion = coerceArray(parseVersion(newVersion)?.release);
const baseLen = baseVersion.length;
const newVerLen = futureVersion.length;
// trim redundant trailing version specifiers
if (baseLen < newVerLen) {
return (
range.operator + futureVersion.slice(0, baseVersion.length).join('.')
);
}
// pad with specifiers
if (baseLen > newVerLen) {
for (let i = baseLen - newVerLen - 1; i >= 0; i--) {
futureVersion.push(0);
}
return range.operator + futureVersion.join('.');
}
return range.operator + newVersion;
}
if (['==', '<='].includes(range.operator)) {
if (lte(newVersion, range.version)) {
return range.operator + range.version;
}
return range.operator + newVersion;
}
let output = handleUpperBound(range, newVersion);
if (output) {
// manged to update upperbound
// no need to try anything else
return output;
}
output = handleLowerBound(range, newVersion);
if (output) {
return output;
}
// unless PEP440 changes, this won't happen
// istanbul ignore next
logger.error(
{ newVersion, currentValue, range },
'pep440: failed to process range',
);
// istanbul ignore next
return null;
}
/**
* Checks for zero specifiers.
* returns true if one of the bounds' length is < 3.
* @param ranges A {@link Range} array.
* @returns A boolean value
* Used mainly for cosmetics for the rez versioning syntax.
*/
function hasZeroSpecifier(ranges: Range[]): boolean {
return ranges.some((range) => {
const release = parseVersion(range.version)?.release;
return release && release.length < 3;
});
}
function trimTrailingZeros(numbers: number[]): number[] {
let i = numbers.length - 1;
while (numbers[i] === 0) {
i--;
}
return numbers.slice(0, i + 1);
}
function divideCompatibleReleaseRange(currentRange: Range): Range[] {
const currentVersionUpperBound = currentRange.version
.split('.')
.map((num) => parseInt(num, 10));
if (currentVersionUpperBound.length > 1) {
currentVersionUpperBound.splice(-1);
}
currentVersionUpperBound[currentVersionUpperBound.length - 1] += 1;
return [
{ operator: '>=', version: currentRange.version },
{
operator: '<',
version: currentVersionUpperBound.join('.'),
},
];
}
function handleWidenStrategy(
{ currentValue, rangeStrategy, currentVersion, newVersion }: NewValueConfig,
ranges: Range[],
): (string | null)[] {
// newVersion is within range
if (satisfies(newVersion, currentValue)) {
return [currentValue];
}
let rangePrecision = getRangePrecision(ranges);
const trimZeros = hasZeroSpecifier(ranges);
let newRanges: Range[] = [];
if (ranges.length === 1 && ranges[0].operator === '~=') {
// If range operator is '~=', divide the range into its logical equivalent.
// Example: ~=1.0 --> >=1.0,<2
newRanges = divideCompatibleReleaseRange(ranges[0]);
} else {
newRanges = ranges;
}
return newRanges.map((range) => {
// newVersion is over the upper bound
if (range.operator === '<' && gte(newVersion, range.version)) {
const upperBound = coerceArray(parseVersion(range.version)?.release);
const len = upperBound.length;
// Match the precision of the smallest specifier if other than 0
if (upperBound[len - 1] !== 0) {
const key =
PrecisionUserPolicyMap[
(len - 1) as keyof typeof PrecisionUserPolicyMap
];
rangePrecision =
UserPolicyPrecisionMap[key as keyof typeof UserPolicyPrecisionMap];
}
let futureVersion = getFutureVersion(
rangePrecision,
newVersion,
range.version,
);
if (trimZeros) {
futureVersion = trimTrailingZeros(futureVersion);
}
return range.operator + futureVersion.join('.');
}
// default
return updateRangeValue(
{
currentValue,
rangeStrategy,
currentVersion,
newVersion,
},
range,
);
});
}
function handleReplaceStrategy(
{ currentValue, rangeStrategy, currentVersion, newVersion }: NewValueConfig,
ranges: Range[],
): (string | null)[] {
// newVersion is within range
if (satisfies(newVersion, currentValue)) {
return [currentValue];
}
const trimZeros = hasZeroSpecifier(ranges);
return ranges.map((range) => {
// newVersion is over the upper bound
if (range.operator === '<' && gte(newVersion, range.version)) {
const rangePrecision = getRangePrecision(ranges);
let futureVersion = getFutureVersion(
rangePrecision,
newVersion,
range.version,
);
if (trimZeros) {
futureVersion = trimTrailingZeros(futureVersion);
}
return range.operator + futureVersion.join('.');
}
// handle lower bound
if (['>', '>='].includes(range.operator)) {
if (lte(newVersion, range.version)) {
// this looks like a rollback
return `>=${newVersion}`;
}
// update the lower bound to reflect the accepted new version
const lowerBound = coerceArray(parseVersion(range.version)?.release);
const rangePrecision = lowerBound.length - 1;
let newBase = getFutureVersion(rangePrecision, newVersion);
if (trimZeros) {
newBase = trimTrailingZeros(newBase);
}
// trim last element of the newBase when new accepted version is out of range.
// example: let new bound be >8.2.5 & newVersion be 8.2.5
// return value will be: >8.2
if (
range.operator === '>' &&
newVersion === newBase.join('.') &&
newBase.length > 1
) {
newBase.pop();
}
return range.operator + newBase.join('.');
}
// default
return updateRangeValue(
{
currentValue,
rangeStrategy,
currentVersion,
newVersion,
},
range,
);
});
}
function handleBumpStrategy(
{ currentValue, rangeStrategy, currentVersion, newVersion }: NewValueConfig,
ranges: Range[],
): (string | null)[] {
return ranges.map((range) => {
// bump lower bound to current new version
if (range.operator === '>=') {
return range.operator + newVersion;
}
return updateRangeValue(
{
currentValue,
rangeStrategy,
currentVersion,
newVersion,
},
range,
);
});
}
export function checkRangeAndRemoveUnnecessaryRangeLimit(
rangeInput: string,
newVersion: string,
): string {
let newRange: string = rangeInput;
if (rangeInput.includes(',')) {
const newRes = rangeInput.split(',');
if (
newRes[0].includes('.*') &&
newRes[0].includes('==') &&
newRes[1].includes('>=') &&
satisfies(newVersion, newRes[0])
) {
newRange = newRes[0];
}
} else {
return rangeInput;
}
return newRange;
}