Files
renovate/lib/util/package-rules/index.ts
Eyal Zekaria 0e90c69f91 perf(package-rules): avoid re-cloning invariant packageRules array on every rule match (#44781)
perf(package-rules): avoid re-cloning packageRules array on every rule merge

applyPackageRules() merges each matching rule via mergeChildConfig(),
which deep-clones the entire accumulated config — including the
invariant packageRules array itself. On repos with many dependency
instances and large rule arrays (e.g. vulnerabilityAlerts force-rules
embedding advisory texts), this cloning dominates the whole run
(observed at ~66% of total CPU in a production profile).

Exclude packageRules from the working config while rules are applied
and restore it afterwards, preserving the previous merge semantics for
rules that themselves carry nested packageRules, and for null/empty/
absent inputs.

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

141 lines
4.7 KiB
TypeScript

import { isNullOrUndefined, isString, isTruthy } from '@sindresorhus/is';
import _slugify from 'slugify';
import type {
PackageRule,
PackageRuleInputConfig,
} from '../../config/types.ts';
import { mergeChildConfig } from '../../config/utils.ts';
import { logger } from '../../logger/index.ts';
import type { StageName } from '../../types/skip-reason.ts';
import { coerceArray } from '../array.ts';
import { compile } from '../template/index.ts';
import matchers from './matchers.ts';
const slugify = _slugify;
async function matchesRule(
inputConfig: PackageRuleInputConfig,
packageRule: PackageRule,
): Promise<boolean> {
for (const matcher of matchers) {
const isMatch = await matcher.matches(inputConfig, packageRule);
// no rules are defined
if (isNullOrUndefined(isMatch)) {
continue;
}
if (!isTruthy(isMatch)) {
return false;
}
}
return true;
}
export async function applyPackageRules<T extends PackageRuleInputConfig>(
inputConfig: T,
stageName?: StageName,
): Promise<T> {
let config = { ...inputConfig };
const packageRules = coerceArray(config.packageRules);
// The `packageRules` array is invariant while rules are being applied, and
// can be very large (e.g. vulnerability alerts append rules embedding full
// advisory texts). Remove it from the working config so mergeChildConfig()
// does not deep-clone the whole array once per matched rule, then restore
// it afterwards.
delete config.packageRules;
logger.trace(
{ dependency: config.depName, packageRules },
`Checking against ${packageRules.length} packageRules`,
);
for (const packageRule of packageRules) {
// This rule is considered matched if there was at least one positive match and no negative matches
if (await matchesRule(config, packageRule)) {
// Package rule config overrides any existing config
const toApply = removeMatchers({ ...packageRule });
if (config.groupSlug && packageRule.groupName && !packageRule.groupSlug) {
// Need to apply groupSlug otherwise the existing one will take precedence
toApply.groupSlug = slugify(packageRule.groupName, {
lower: true,
});
}
if (
// if it's got higher precedence, as it's a force'd config option
// multiple force'd config options are "last defined wins"
toApply.force?.enabled === false ||
// otherwise, if it has regular precedence, compare
(toApply.enabled === false && config.enabled !== false)
) {
config.skipReason = 'package-rules';
if (stageName) {
config.skipStage = stageName;
}
}
if (toApply.force?.enabled || toApply.enabled) {
delete config.skipReason;
delete config.skipStage;
}
if (
isString(toApply.overrideDatasource) &&
toApply.overrideDatasource !== config.datasource
) {
logger.debug(
`Overriding datasource from ${config.datasource} to ${toApply.overrideDatasource} for ${config.depName}`,
);
config.datasource = toApply.overrideDatasource;
}
if (
isString(toApply.overrideDepName) &&
toApply.overrideDepName !== config.depName
) {
logger.debug(
`Overriding depName from ${config.depName} to ${toApply.overrideDepName}`,
);
config.depName = compile(toApply.overrideDepName, config);
}
if (
isString(toApply.overridePackageName) &&
toApply.overridePackageName !== config.packageName
) {
logger.debug(
`Overriding packageName from ${config.packageName} to ${toApply.overridePackageName} for ${config.depName}`,
);
config.packageName = compile(toApply.overridePackageName, config);
}
if (isString(toApply.sourceUrl)) {
toApply.sourceUrl = compile(toApply.sourceUrl, config);
}
delete toApply.overrideDatasource;
delete toApply.overrideDepName;
delete toApply.overridePackageName;
config = mergeChildConfig(config, toApply);
}
}
// Restore the rules. If any applied rule carried nested `packageRules`
// (e.g. from a resolved preset), preserve the concat-merge semantics that
// mergeChildConfig() would previously have applied.
if (config.packageRules) {
config.packageRules = packageRules.concat(config.packageRules);
} else if ('packageRules' in inputConfig) {
config.packageRules = inputConfig.packageRules;
}
return config;
}
function removeMatchers<T extends Record<string, unknown>>(
packageRule: T,
): Record<string, unknown> & {
force?: Record<string, unknown>;
} {
for (const key of Object.keys(packageRule)) {
if (key.startsWith('match') || key.startsWith('exclude')) {
delete packageRule[key];
}
}
return packageRule;
}