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

416 lines
11 KiB
TypeScript

import { isString } from '@sindresorhus/is';
import { split } from 'shlex';
import upath from 'upath';
import { logger } from '../../../logger/index.ts';
import { coerceArray, isNotNullOrUndefined } from '../../../util/array.ts';
import type {
ExecOptions,
ExtraEnv,
ToolConstraint,
} from '../../../util/exec/types.ts';
import { ensureCacheDir } from '../../../util/fs/index.ts';
import { ensureLocalPath } from '../../../util/fs/util.ts';
import * as hostRules from '../../../util/host-rules.ts';
import { regEx } from '../../../util/regex.ts';
import { parseUrl } from '../../../util/url.ts';
import type { PackageFileContent, UpdateArtifactsConfig } from '../types.ts';
import type {
CommandType,
PipCompileArgs,
SupportedManagers,
} from './types.ts';
export function getPythonVersionConstraint(
config: UpdateArtifactsConfig,
extractedPythonVersion: string | undefined,
): string | undefined | null {
const { constraints = {} } = config;
const { python } = constraints;
if (python) {
logger.debug('Using python constraint from config');
return python;
}
if (extractedPythonVersion) {
logger.debug('Using python constraint extracted from the lock file');
return `==${extractedPythonVersion}`;
}
return undefined;
}
export function getPipToolsVersionConstraint(
config: UpdateArtifactsConfig,
): string {
const { constraints = {} } = config;
const { pipTools } = constraints;
if (isString(pipTools)) {
logger.debug('Using pipTools constraint from config');
return pipTools;
}
return '';
}
export function getUvVersionConstraint(config: UpdateArtifactsConfig): string {
const { constraints = {} } = config;
const { uv } = constraints;
if (isString(uv)) {
logger.debug('Using uv constraint from config');
return uv;
}
return '';
}
export function getToolVersionConstraint(
config: UpdateArtifactsConfig,
commandType: CommandType,
): ToolConstraint {
if (commandType === 'uv') {
return {
toolName: 'uv',
constraint: getUvVersionConstraint(config),
};
}
return {
toolName: 'pip-tools',
constraint: getPipToolsVersionConstraint(config),
};
}
export async function getExecOptions(
config: UpdateArtifactsConfig,
commandType: CommandType,
cwd: string,
extraEnv: ExtraEnv<string>,
extractedPythonVersion: string | undefined,
): Promise<ExecOptions> {
const constraint = getPythonVersionConstraint(config, extractedPythonVersion);
const execOptions: ExecOptions = {
cwd: ensureLocalPath(cwd),
docker: {},
toolConstraints: [
{
toolName: 'python',
constraint,
},
getToolVersionConstraint(config, commandType),
],
extraEnv: {
PIP_CACHE_DIR: await ensureCacheDir('pip'),
PIP_NO_INPUT: 'true', // ensure pip doesn't block forever waiting for credentials on stdin
PIP_KEYRING_PROVIDER: 'import',
PYTHON_KEYRING_BACKEND: 'keyrings.envvars.keyring.EnvvarsKeyring',
...extraEnv,
},
};
return execOptions;
}
export const constraintLineRegex = regEx(
/^(?:#.*?\r?\n)+# {4}(?<command>\S*)(?<arguments> .*?)?\r?\n/,
);
export const disallowedPipOptions = [
'--no-header', // header is required by this manager
];
const commonOptionsWithArguments = [
'--output-file',
'--extra',
'--extra-index-url',
];
const pipOptionsWithArguments = [
'--resolver',
'--constraint',
...commonOptionsWithArguments,
];
const uvOptionsWithArguments = [
'--constraints',
'--constraint', // singular alias accepted by uv
'--python-version',
'--no-emit-package',
'--prerelease',
'--format',
'--resolution',
'--fork-strategy',
'--exclude-newer',
'--exclude-newer-package',
'--group',
'--override',
'--overrides',
...commonOptionsWithArguments,
];
export const optionsWithArguments = [
...pipOptionsWithArguments,
...uvOptionsWithArguments,
];
const allowedCommonOptions = [
'-v',
'--generate-hashes',
'--emit-index-url',
'--index-url',
'--all-extras',
];
export const allowedOptions: Record<CommandType, string[]> = {
'pip-compile': [
'--allow-unsafe',
'--generate-hashes',
'--no-emit-index-url',
'--strip-extras',
...allowedCommonOptions,
...pipOptionsWithArguments,
],
uv: [
'--no-strip-extras',
'--universal',
...allowedCommonOptions,
...uvOptionsWithArguments,
],
custom: [],
};
// TODO(not7cd): test on all correct headers, even with CUSTOM_COMPILE_COMMAND
export function extractHeaderCommand(
content: string,
fileName: string,
): PipCompileArgs {
const compileCommand = constraintLineRegex.exec(content);
if (compileCommand?.groups === undefined) {
throw new Error(
`Failed to extract command from header in ${fileName} ${content}`,
);
}
logger.trace(
`pip-compile: found header in ${fileName}: \n${compileCommand[0]}`,
);
const command = compileCommand.groups.command;
const argv = [command];
let commandType: CommandType;
if (command === 'pip-compile') {
commandType = 'pip-compile';
} else if (command === 'uv') {
commandType = 'uv';
} else {
commandType = 'custom';
}
if (compileCommand.groups.arguments) {
argv.push(...split(compileCommand.groups.arguments));
}
logger.debug(
{ fileName, argv, commandType },
`pip-compile: extracted command from header`,
);
const result: PipCompileArgs = {
argv,
command,
commandType,
outputFile: '',
sourceFiles: [],
};
for (const arg of argv.slice(1)) {
if (commandType === 'uv' && ['pip', 'compile'].includes(arg)) {
continue;
}
// TODO(not7cd): check for "--option -- argument" case
if (!arg.startsWith('-')) {
result.sourceFiles.push(arg);
continue;
}
throwForDisallowedOption(arg);
throwForNoEqualSignInOptionWithArgument(arg);
throwForUnknownOption(commandType, arg);
if (arg.includes('=')) {
const [option, value] = arg.split('=');
if (option === '--extra') {
result.extra = coerceArray(result.extra);
result.extra.push(value);
} else if (option === '--extra-index-url') {
result.extraIndexUrl = coerceArray(result.extraIndexUrl);
result.extraIndexUrl.push(value);
// TODO: add to secrets? next PR
} else if (['--constraint', '--constraints'].includes(option)) {
result.constraintsFiles = coerceArray(result.constraintsFiles);
result.constraintsFiles.push(value);
} else if (['--override', '--overrides'].includes(option)) {
result.overridesFiles = coerceArray(result.overridesFiles);
result.overridesFiles.push(value);
} else if (option === '--output-file') {
if (result.outputFile) {
throw new Error('Cannot use multiple --output-file options');
}
result.outputFile = upath.normalize(value);
} else if (option === '--python-version') {
result.pythonVersion = value;
} else if (option === '--index-url') {
if (result.indexUrl) {
throw new Error('Cannot use multiple --index-url options');
}
result.indexUrl = value;
// TODO: add to secrets? next PR
} else {
logger.debug({ option }, `pip-compile: option not handled`);
}
continue;
}
if (arg === '--no-emit-index-url') {
result.noEmitIndexUrl = true;
continue;
}
if (arg === '--emit-index-url') {
result.emitIndexUrl = true;
continue;
}
if (arg === '--all-extras') {
result.allExtras = true;
continue;
}
logger.debug({ option: arg }, `pip-compile: option not handled`);
}
logger.trace(
{
...result,
},
'Parsed pip-compile command from header',
);
if (result.noEmitIndexUrl && result.emitIndexUrl) {
throw new Error('Cannot use both --no-emit-index-url and --emit-index-url');
}
if (result.sourceFiles.length === 0) {
throw new Error(
'No source files detected in command, pass at least one package file explicitly',
);
}
return result;
}
const pythonVersionRegex = regEx(
/^(?:#.*?\r?\n)*# This file is autogenerated by pip-compile with Python (?<pythonVersion>\d+(?:\.\d+)*)\s/,
'i',
);
export function extractPythonVersion(
content: string,
fileName: string,
): string | undefined {
const match = pythonVersionRegex.exec(content);
if (match?.groups === undefined) {
logger.warn(
{ fileName, content },
'pip-compile: failed to extract Python version from header in file',
);
return undefined;
}
logger.trace(
`pip-compile: found Python version header in ${fileName}: \n${match[0]}`,
);
const { pythonVersion } = match.groups;
logger.debug(
{ fileName, pythonVersion },
`pip-compile: extracted Python version from header`,
);
return pythonVersion;
}
function throwForDisallowedOption(arg: string): void {
if (disallowedPipOptions.includes(arg)) {
throw new Error(`Option ${arg} not allowed for this manager`);
}
}
function throwForNoEqualSignInOptionWithArgument(arg: string): void {
if (optionsWithArguments.includes(arg)) {
throw new Error(
`Option ${arg} must have equal sign '=' separating it's argument`,
);
}
}
function throwForUnknownOption(commandType: CommandType, arg: string): void {
if (arg.includes('=')) {
const [option] = arg.split('=');
if (allowedOptions[commandType].includes(option)) {
return;
}
}
if (allowedOptions[commandType].includes(arg)) {
return;
}
throw new Error(`Option ${arg} not supported (yet)`);
}
function getRegistryCredEnvVars(
url: URL,
index: number,
): Record<string, string> {
const hostRule = hostRules.find({ url: url.href });
logger.debug(hostRule, `Found host rule for url ${url.href}`);
const ret: Record<string, string> = {};
if (!!hostRule.username || !!hostRule.password) {
ret[`KEYRING_SERVICE_NAME_${index}`] = url.hostname;
ret[`KEYRING_SERVICE_USERNAME_${index}`] = hostRule.username ?? '';
ret[`KEYRING_SERVICE_PASSWORD_${index}`] = hostRule.password ?? '';
}
return ret;
}
function cleanUrl(url: string): URL | null {
// Strip everything but protocol, host, and port
const urlObj = parseUrl(url);
if (!urlObj) {
return null;
}
// origin of a valid URL is always parseable
return parseUrl(urlObj.origin);
}
export function getRegistryCredVarsFromPackageFiles(
packageFiles: PackageFileContent[],
): ExtraEnv<string> {
const urls: string[] = [];
for (const packageFile of packageFiles) {
urls.push(
...coerceArray(packageFile.registryUrls),
...coerceArray(packageFile.additionalRegistryUrls),
);
}
logger.debug(urls, 'Extracted registry URLs from package files');
const uniqueHosts = new Set<URL>(
urls.map(cleanUrl).filter(isNotNullOrUndefined),
);
let allCreds: ExtraEnv<string> = {};
for (const [index, host] of [...uniqueHosts].entries()) {
const hostCreds = getRegistryCredEnvVars(host, index);
allCreds = {
...allCreds,
...hostCreds,
};
}
return allCreds;
}
export function matchManager(filename: string): SupportedManagers | 'unknown' {
if (filename.endsWith('setup.py')) {
return 'pip_setup';
}
if (filename.endsWith('setup.cfg')) {
return 'setup-cfg';
}
if (filename.endsWith('pyproject.toml')) {
return 'pep621';
}
// naive, could be improved, maybe use pip_requirements.managerFilePatterns
if (filename.endsWith('.in') || filename.endsWith('.txt')) {
return 'pip_requirements';
}
return 'unknown';
}