Files
renovate/lib/modules/versioning/pep440/range.ts
Michael Kriese 59852479bf test: close branch coverage gaps outside the manager tree (#46252)
* test(versioning): cover four uncovered guards

- `cargo`: a converted range whose operators are already attached to
  their versions, so `npm2cargo` leaves the elements alone
- `hashicorp`: a widen on a complex range ending in `>=`, which the
  underlying semver implementation refuses and returns null for
- `poetry`: a version list holding an unparseable entry, for both
  `getSatisfyingVersion` and `minSatisfyingVersion`, plus the same widen
  refusal, which falls back to the current value
- `swift`: an open-ended range whose bound is not a valid semver

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* refactor(versioning): drop two guards that cannot be false

- `hermit`: `major` is the only mandatory group in the version regex, so
  the `if (major)` around the first release segment always holds; the
  sibling parser already pushes it unconditionally
- `rpm`: after `c1 === c2` is ruled out, one of `c1 > c2` and `c1 < c2`
  must hold, so the second comparison becomes a ternary

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* test(versioning/gradle): cover three uncovered guards

- `getMajor` on a version whose leading token is not a number
- `getSatisfyingVersion` with a later match that is not greater than the
  one already held
- `minSatisfyingVersion` with a later match that is not smaller than the
  one already held

Two branches in `compare.ts` are still open and are looked at next.

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* test(versioning/ivy): cover a later match that is not the greatest

`getSatisfyingVersion` kept every matching version it walked past, so the
guard that holds on to the one already found was never taken.

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* refactor(versioning): drop three guards that cannot be false

- `gradle`: `tokenCmp` compares two tokens whose only types are `Number`
  and `String`, so once both-numbers and both-strings are handled
  exactly one side is a number; the chain becomes a ternary and the
  unreachable `return 0` goes
- `ivy`: `tokenize` always yields at least one token for a non-empty
  version, so the length check around the last one always holds
- `ivy`: `rangeToStr` only returns null for a null range, which the
  `length === 1` check above it already rules out

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* test(versioning/nuget): cover a later match that is not the greatest

Both loops in `getSatisfyingVersion` walked version lists that only ever
climbed, so the guard that holds on to the candidate already found was
never taken -- once for a bracket range, once for a bare version acting
as a minimum.

Also pin down that a floating range needs a major component: `-*` alone
does not parse.

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* refactor(versioning): drop three more guards that cannot be false

- `maven`: the interval picked for auto-extension is only picked when the
  new value is at or past its upper bound, and that upper bound is the
  lower one incremented, so the new value never sorts below the lower
  bound
- `maven`: an interval never has both bounds open, because the parser
  only nulls one side when the other is a version
- `nuget`: a floating range always carries a major component, so neither
  the parser's NaN check nor the serializer's undefined check can fail

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* refactor(versioning/pep440): drop three unreachable range paths

`updateRangeValue` handles `!=`, a `.*` prefix, `~=`, `==` and `<=`
itself, `handleUpperBound` handles `<`, and `parseCurrentRange` rejects
`===`. Only `>` and `>=` can reach `handleLowerBound`, so its operator
check always holds and it never returns null. Making it total removes
the null check at the call site as well.

This does drop the `logger.error` that followed, which was guarded by a
comment saying it could not happen unless pep440 itself changed.

`isLessThanRange` classifies the same fixed operator set, so the last
check there is the only one left and its fallback was dead.

`divideCompatibleReleaseRange` only ever sees a `~=` version, and pep440
requires at least two release components after that operator, so there
is always a component to drop. A new test pins down that `~=7` is not a
valid range.

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* test(versioning): cover three more uncovered guards

- `elm`: a widen where the new version sits below the lower bound, so
  the upper bound is kept rather than extended
- `hex`: a widen on a complex range ending in `>=`, which the underlying
  semver implementation refuses and returns null for
- `rust-release-channel`: a concrete current version paired with a
  channel name as the new version, which falls through to the new
  version unchanged

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* refactor(versioning): drop two more guards that cannot be false

- `composer`: `getNewValue` falls back to the new version whenever
  nothing else matched, so it never returns null and the recursive call
  in the or-range path always has a value to work with
- `docker`: a version's prerelease is always a string, so once an empty
  one on either side is handled, the remaining pair is both non-empty
  and unequal

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* test(config,datasource): cover two more uncovered guards

- `config/validation`: `hostRules[].allowInternal` nested inside a
  `packageRules[]` entry, which reports a plain configuration error
  rather than a security one, because nested host rules are never applied
- `datasource/docker`: a non-list manifest whose response carries no
  `docker-content-digest` header, so the digest falls back to the hash of
  the manifest body

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* refactor(config,datasource): unwind three branches v8 could not count

- `config/decrypt` and `datasource/docker/common` each picked a response
  with an `await` inside a ternary. v8 gives the branch that follows such
  a ternary a negative hit count, which the reporters read as uncovered
  even though both paths run. Spelling the choice as an if/else makes the
  count come out right; behaviour is unchanged.
- `datasource/index` and `datasource/npm/npmrc` each ended an exhaustive
  chain with an `else if` whose implicit else v8 counts but nothing can
  reach: `registryStrategy` is one of three literals, and `npmrc` starts
  as an object and is only ever reassigned to one.

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* test(workers/global): cover seven uncovered guards

The config parsers rewrite legacy boolean spellings of `dryRun`,
`requireConfig` and `platformCommit`, but nothing passed a value that was
already current, so the fall-through of each chain went untaken. Cover it
from both the CLI and the environment.

Also cover a datasource env var that names no auth field, one whose value
is undefined, a default config file that cannot be read, and the error
`isLimitReached` throws when asked for a concurrent limit without a
branch config. That last one had an ignore hint saying it should not
happen; the error message documents the contract, so it is now tested and
the hint is gone.

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* refactor(workers/global): drop a guard that cannot be false

`getConfig` in the additional-config-file parser returns early unless
`RENOVATE_ADDITIONAL_CONFIG_FILE` is set, so by the time a parse error is
being classified the variable is always present.

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* refactor(workers/repository): drop a dead changelog file lookup

The gitea, gitlab and forgejo changelog sources each declared `files` as
an empty array and then guarded the only assignment to it with
`if (!files.length)`, which is always true one line after the
declaration. Collapse it to the filter itself.

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* ci: ratchet the branch coverage threshold

A full run on this branch reports 100% statements (49471/49471), 100%
functions (7381/7381), 100% lines (48961/48961) and 99.73% branches
(25690/25759), up from 99.53% before this branch's work. The threshold
moves to 99.71, keeping the same rounding-down margin as before.

Statements, functions and lines are already pinned at 100 and stay
there.

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* refactor(config,datasource): say why the ternary was unwound

Both sites now carry a comment explaining that the if/else is deliberate:
v8 gives the branch following an `await` inside a ternary a negative hit
count, which the coverage reporters read as uncovered.

`apiCheckResponse` also gets an explicit `HttpResponse<unknown>` type,
which is what the two branches have in common and what
`isECRMaxResultsResponse` already expects.

Also fix a type error in the new config file spec: the `pathExists` spy
needs the same `as never` cast the surrounding tests use.

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* refactor(versioning/pep440): use a template literal in handleLowerBound

The exclude-style return is the one line this branch rewrote in that
function, so it now builds its string with a template literal instead of
the `+` operator.

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

* test(workers/global): drop the cast from the limits throw test

Calling the overload with a non-null assertion on the config argument
says the same thing without retyping the whole function.

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>

---------

Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>
2026-09-16 12:03:23 +00:00

562 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);
}
// the split regex only yields pep440 operators, and `>` is the one
// left unhandled above
return lte(input, version);
});
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 {
// used to mark minimum supported version
// lower the bound if the new version is lower than current range
if (lte(newVersion, range.version)) {
// this looks like a rollback
return `>=${newVersion}`;
}
// otherwise, treat it same as exclude
return `${range.operator}${range.version}`;
}
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;
}
const output = handleUpperBound(range, newVersion);
if (output) {
// manged to update upperbound
// no need to try anything else
return output;
}
// `!=`, a `.*` prefix, `~=`, `==`, `<=` and `<` are all handled above, and
// `===` is rejected when the range is parsed, so only `>` and `>=` are left
return handleLowerBound(range, newVersion);
}
/**
* 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));
// pep440 requires at least two release components after `~=`, so there is
// always one to drop here
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;
}