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

413 lines
13 KiB
TypeScript

import { isNumber } from '@sindresorhus/is';
import semver from 'semver';
import { parseRange } from 'semver-utils';
import { logger } from '../../../logger/index.ts';
import type { RangeStrategy } from '../../../types/versioning.ts';
import { regEx } from '../../../util/regex.ts';
import { api as npm } from '../npm/index.ts';
import type { NewValueConfig, VersioningApi } from '../types.ts';
export const id = 'composer';
export const displayName = 'Composer';
export const urls = [
'[Composer versions](https://getcomposer.org/doc/articles/versions.md)',
'[composer/semver package](https://packagist.org/packages/composer/semver)',
'[Tilde and caret constraints](https://madewithlove.be/tilde-and-caret-constraints/)',
'[Composer semver checker](https://semver.mwl.be)',
];
export const supportsRanges = true;
export const supportedRangeStrategies: RangeStrategy[] = [
'bump',
'widen',
'replace',
'update-lockfile',
];
function getVersionParts(input: string): [string, string] {
const versionParts = input.split('-');
if (versionParts.length === 1) {
return [input, ''];
}
return [versionParts[0], `-${versionParts[1]}`];
}
function padZeroes(input: string): string {
const [output, stability] = getVersionParts(input);
const sections = output.split('.');
while (sections.length < 3) {
sections.push('0');
}
return `${sections.join('.')}${stability}`;
}
function convertStabilityModifier(input: string): string {
// Handle stability modifiers.
const versionParts = input.split('@');
if (versionParts.length === 1) {
return input;
}
// 1.0@beta2 to 1.0-beta.2
const stability = versionParts[1].replace(
regEx(/(?:^|\s)(?<label>beta|alpha|rc)(?<num>[1-9][0-9]*)(?: |$)/gi),
'$<label>.$<num>',
);
// If there is a stability part, npm semver expects the version
// to be full
return `${padZeroes(versionParts[0])}-${stability}`;
}
function normalizeVersion(input: string): string {
let output = input;
// Strip leading + prefix (not a valid composer constraint operator)
output = output.replace(regEx(/^\+/), '');
output = output.replace(regEx(/(?<prefix>^|>|>=|\^|~)v/i), '$<prefix>');
return convertStabilityModifier(output);
}
/**
* @param versions Version list in any format, it recognizes the specific patch format x.x.x-pXX
* @param range Range in composer format
* @param minMode If true, it will calculate minSatisfyingVersion, if false, it calculates the maxSatisfyingVersion
* @returns min or max satisfyingVersion from the input
*/
function calculateSatisfyingVersionIntenal(
versions: string[],
range: string,
minMode: boolean,
): string | null {
// Because composer -p versions are considered stable, we have to remove the suffix for the npm.XXX functions.
const versionsMapped = versions.map((x) => {
return {
origianl: x,
cleaned: removeComposerSpecificPatchPart(x),
npmVariant: composer2npm(removeComposerSpecificPatchPart(x)[0]),
};
});
const npmVersions = versionsMapped.map((x) => x.npmVariant);
const npmVersion = minMode
? npm.minSatisfyingVersion(npmVersions, composer2npm(range))
: npm.getSatisfyingVersion(npmVersions, composer2npm(range));
if (!npmVersion) {
return null;
}
// After we find the npm versions, we select from them back in the mapping the possible patches.
const candidates = versionsMapped
.filter((x) => x.npmVariant === npmVersion)
.sort((a, b) => (minMode ? 1 : -1) * sortVersions(a.origianl, b.origianl));
return candidates[0].origianl;
}
/**
* @param intput Version in any format, it recognizes the specific patch format x.x.x-pXX
* @returns If input contains the specific patch, it returns the input with removed the patch and true, otherwise it retunrs the same string and false.
*/
function removeComposerSpecificPatchPart(input: string): [string, boolean] {
// the regex is based on the original from composer implementation https://github.com/composer/semver/blob/fa1ec24f0ab1efe642671ec15c51a3ab879f59bf/src/VersionParser.php#L137
const pattern = regEx(
/^v?\d+(?:\.\d+(?:\.\d+(?:\.\d+)?)?)?(?<suffix>-p[1-9]\d*)$/gi,
);
const match = pattern.exec(input);
return match
? [input.replace(match.groups!.suffix, ''), true]
: [input, false];
}
function composer2npm(input: string): string {
return input
.split(regEx(/\s*\|\|?\s*/g))
.map((part): string => {
const cleanInput = normalizeVersion(part);
if (npm.isVersion(cleanInput)) {
return cleanInput;
}
if (npm.isVersion(padZeroes(cleanInput))) {
return padZeroes(cleanInput);
}
const [versionId, stability] = getVersionParts(cleanInput);
let output = versionId;
// ~4 to ^4 and ~4.1 to ^4.1
output = output.replace(
regEx(/(?:^|\s)~(?<minVersion>[1-9][0-9]*(?:\.[0-9]*)?)(?: |$)/g),
'^$<minVersion>',
);
// ~0.4 to >=0.4 <1
output = output.replace(
regEx(/(?:^|\s)~(?<minVersion>0\.[1-9][0-9]*)(?: |$)/g),
'>=$<minVersion> <1',
);
// add extra digits to <8-DEV and <8.0-DEV
output = output
.replace(regEx(/^(?<version><\d+(?:\.\d+)?)$/g), '$<version>.0')
.replace(regEx(/^(?<version><\d+(?:\.\d+)?)$/g), '$<version>.0');
return output + stability;
})
.map((part) =>
part.replace(
regEx(/(?<letter>[a-z])(?<digit>[0-9])/gi),
'$<letter>.$<digit>',
),
)
.join(' || ');
}
function equals(a: string, b: string): boolean {
return npm.equals(composer2npm(a), composer2npm(b));
}
function getMajor(version: string): number | null {
const semverVersion = semver.coerce(composer2npm(version));
return semverVersion ? npm.getMajor(semverVersion) : null;
}
function getMinor(version: string): number | null {
const semverVersion = semver.coerce(composer2npm(version));
return semverVersion ? npm.getMinor(semverVersion) : null;
}
function getPatch(version: string): number | null {
const semverVersion = semver.coerce(composer2npm(version));
// This returns only the numbers without the optional `-pXX` patch version supported by composer. Fixing that would require a bigger
// refactoring, because the API supports only numbers.
return semverVersion ? npm.getPatch(semverVersion) : null;
}
function isGreaterThan(a: string, b: string): boolean {
return sortVersions(a, b) === 1;
}
function isLessThanRange(version: string, range: string): boolean {
return !!npm.isLessThanRange?.(composer2npm(version), composer2npm(range));
}
function isSingleVersion(input: string): boolean {
return !!input && npm.isSingleVersion(composer2npm(input));
}
function isStable(version: string): boolean {
if (version) {
// Composer considers patches `-pXX` as stable: https://github.com/composer/semver/blob/fa1ec24f0ab1efe642671ec15c51a3ab879f59bf/src/VersionParser.php#L568 but npm not.
// In order to be able to use the standard npm.isStable function, we remove the potential patch version for the check.
const [withoutPatch] = removeComposerSpecificPatchPart(version);
return npm.isStable(composer2npm(withoutPatch));
}
return false;
}
export function isValid(input: string): boolean {
return !!input && npm.isValid(composer2npm(input));
}
export function isVersion(input: string): boolean {
return !!input && npm.isVersion(composer2npm(input));
}
function matches(version: string, range: string): boolean {
return npm.matches(composer2npm(version), composer2npm(range));
}
function getSatisfyingVersion(
versions: string[],
range: string,
): string | null {
return calculateSatisfyingVersionIntenal(versions, range, false);
}
function minSatisfyingVersion(
versions: string[],
range: string,
): string | null {
return calculateSatisfyingVersionIntenal(versions, range, true);
}
function subset(subRange: string, superRange: string): boolean | undefined {
try {
return npm.subset!(composer2npm(subRange), composer2npm(superRange));
} catch (err) {
logger.trace({ err }, 'composer.subset error');
return false;
}
}
function intersects(subRange: string, superRange: string): boolean {
try {
return npm.intersects!(composer2npm(subRange), composer2npm(superRange));
} catch (err) {
logger.trace({ err }, 'composer.intersects error');
return false;
}
}
function getNewValue({
currentValue,
rangeStrategy,
currentVersion,
newVersion,
}: NewValueConfig): string | null {
if (rangeStrategy === 'update-lockfile') {
if (matches(newVersion, currentValue)) {
return currentValue;
}
return getNewValue({
currentValue,
rangeStrategy: 'replace',
currentVersion,
newVersion,
});
}
const currentMajor = currentVersion ? getMajor(currentVersion) : null;
const toMajor = getMajor(newVersion);
const toMinor = getMinor(newVersion);
let newValue: string | null = null;
if (isVersion(currentValue)) {
newValue = newVersion;
} else if (regEx(/^[~^](?:0\.[1-9][0-9]*)$/).test(currentValue)) {
const operator = currentValue.substring(0, 1);
// handle ~0.4 case first
if (toMajor === 0) {
// TODO: types (#22198)
newValue = `${operator}0.${toMinor!}`;
} else {
// TODO: types (#22198)
newValue = `${operator}${toMajor!}.0`;
}
} else if (regEx(/^[~^](?:[0-9]*)$/).test(currentValue)) {
// handle ~4 case
const operator = currentValue.substring(0, 1);
// TODO: types (#22198)
newValue = `${operator}${toMajor!}`;
} else if (
toMajor &&
regEx(/^[~^](?:[0-9]*(?:\.[0-9]*)?)$/).test(currentValue)
) {
const operator = currentValue.substring(0, 1);
if (rangeStrategy === 'bump') {
newValue = `${operator}${newVersion}`;
} else if ((isNumber(currentMajor) && toMajor > currentMajor) || !toMinor) {
// handle ~4.1 case
newValue = `${operator}${toMajor}.0`;
} else {
newValue = `${operator}${toMajor}.${toMinor}`;
}
} else if (
currentVersion &&
npm.isVersion(padZeroes(normalizeVersion(newVersion))) &&
npm.isValid(normalizeVersion(currentValue)) &&
composer2npm(currentValue) === normalizeVersion(currentValue)
) {
newValue = npm.getNewValue({
currentValue: normalizeVersion(currentValue),
rangeStrategy,
currentVersion: padZeroes(normalizeVersion(currentVersion)),
newVersion: padZeroes(normalizeVersion(newVersion)),
});
}
if (rangeStrategy === 'widen' && matches(newVersion, currentValue)) {
newValue = currentValue;
} else {
const hasOr = currentValue.includes(' || ');
if (hasOr || rangeStrategy === 'widen') {
const splitValues = currentValue.split('||');
const lastValue = splitValues.at(-1)!;
const replacementValue = getNewValue({
currentValue: lastValue.trim(),
rangeStrategy: 'replace',
currentVersion,
newVersion,
});
if (rangeStrategy === 'replace') {
newValue = replacementValue;
} else if (replacementValue) {
const parsedRange = parseRange(replacementValue);
const element = parsedRange.at(-1)!;
if (element.operator?.startsWith('<')) {
const splitCurrent = currentValue.split(element.operator);
splitCurrent.pop();
newValue = `${splitCurrent.join(element.operator)}${replacementValue}`;
} else {
newValue = `${currentValue} || ${replacementValue}`;
}
}
}
}
if (!newValue) {
logger.warn(
{ currentValue, rangeStrategy, currentVersion, newVersion },
'Unsupported composer value',
);
newValue = newVersion;
}
if (currentValue.split('.')[0].includes('v')) {
newValue = newValue.replace(regEx(/(?<digit>[0-9])/), 'v$<digit>');
}
// Preserve original min-stability specifier
if (currentValue.includes('@')) {
newValue += `@${currentValue.split('@')[1]}`;
}
return newValue;
}
function sortVersions(a: string, b: string): number {
const [aWithoutPatch, aContainsPatch] = removeComposerSpecificPatchPart(a);
const [bWithoutPatch, bContainsPatch] = removeComposerSpecificPatchPart(b);
if (aContainsPatch === bContainsPatch) {
// If both [a and b] contain patch version or both [a and b] do not contain patch version, then npm comparison deliveres correct results
return npm.sortVersions(composer2npm(a), composer2npm(b));
}
if (npm.equals(composer2npm(aWithoutPatch), composer2npm(bWithoutPatch))) {
// If only one [a or b] contains patch version and the parts without patch versions are equal, then the version with patch is greater (this is the case where npm comparison fails)
return aContainsPatch ? 1 : -1;
}
// All other cases can be compared correctly by npm
return npm.sortVersions(composer2npm(a), composer2npm(b));
}
function isCompatible(version: string): boolean {
return isVersion(version);
}
function isBreaking(current: string, version: string): boolean {
return npm.isBreaking!(composer2npm(current), composer2npm(version));
}
export const api: VersioningApi = {
equals,
getMajor,
getMinor,
getPatch,
isBreaking,
isCompatible,
isGreaterThan,
isLessThanRange,
isSingleVersion,
isStable,
isValid,
isVersion,
matches,
getSatisfyingVersion,
minSatisfyingVersion,
getNewValue,
sortVersions,
subset,
intersects,
};
export default api;