mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-31 17:51:49 +00:00
3412de84ce
* 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>
416 lines
11 KiB
TypeScript
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';
|
|
}
|