Files
Sebastian Poxhofer c41b46bac3 feat(logging): do not use dynamic fields in log messages with severity WARN or higher (#44560)
* ci(linter): add renovate/logger-static-message rule

Adds a custom oxlint rule that enforces a static msg component for
logger.warn/error/fatal calls (including logger.once variants) so the
messages can be grouped in metrics and error catching services, per
docs/development/best-practices.md. It also flags metadata properties
named 'error' that hold error-ish values, because the bunyan serializer
only handles the 'err' key.

The rule is enabled as an error for lib/ production code and disabled
for spec files. All 33 pre-existing violations are fixed by moving
interpolated values into the metadata object, making messages static,
and renaming 'error' metadata keys to 'err'. Affected log-message
assertions in co-located specs are updated accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: ignore validator output

* fixup new merged in log messages

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 06:53:37 +00:00

146 lines
4.0 KiB
TypeScript

import { isArray, isPlainObject, isString } from '@sindresorhus/is';
import type { RenovateConfig } from '../config/types.ts';
import {
CONFIG_SECRETS_INVALID,
CONFIG_VALIDATION,
CONFIG_VARIABLES_INVALID,
} from '../constants/error-messages.ts';
import { logger } from '../logger/index.ts';
import { capitalize } from './string.ts';
export interface InterpolatorOptions {
name: 'secrets' | 'variables';
templateRegex: RegExp;
nameRegex: RegExp;
}
export function validateInterpolatedValues(
input: unknown,
options: InterpolatorOptions,
): void {
if (!input) {
return;
}
const { name, nameRegex } = options;
const validationErrors: string[] = [];
if (isPlainObject(input)) {
for (const [key, value] of Object.entries(input)) {
if (!nameRegex.test(key)) {
validationErrors.push(`Invalid ${name} name "${key}"`);
}
if (!isString(value)) {
validationErrors.push(
`${capitalize(name)} values must be strings. Found type ${typeof value} for ${name} ${key}`,
);
}
}
} else {
validationErrors.push(
`Config ${name}s must be a plain object. Found: ${typeof input}`,
);
}
if (validationErrors.length) {
logger.error(
{ name, validationErrors },
'Invalid interpolated values configured',
);
throw new Error(
name === 'secrets' ? CONFIG_SECRETS_INVALID : CONFIG_VARIABLES_INVALID,
);
}
}
function replaceInterpolatedValuesInString(
key: string,
value: string,
input: Record<string, string>,
options: InterpolatorOptions,
): string {
const { name, templateRegex } = options;
// Reset regex lastIndex for global regexes to ensure consistent behavior
templateRegex.lastIndex = 0;
// do nothing if no interpolator template found
if (!templateRegex.test(value)) {
return value;
}
const disallowedPrefixes = ['branch', 'commit', 'group', 'pr', 'semantic'];
if (disallowedPrefixes.some((prefix) => key.startsWith(prefix))) {
const error = new Error(CONFIG_VALIDATION);
error.validationSource = 'config';
error.validationError = `Disallowed ${name} substitution`;
error.validationMessage = `The field \`${key}\` may not use ${name} substitution`;
throw error;
}
return value.replace(templateRegex, (_, key) => {
if (input?.[key]) {
return input[key];
}
const error = new Error(CONFIG_VALIDATION);
error.validationSource = 'config';
error.validationError = `Unknown ${name} name`;
error.validationMessage = `The following ${name} name was not found in config: ${String(
key,
)}`;
throw error;
});
}
export function replaceInterpolatedValuesInObject(
config_: RenovateConfig,
input: Record<string, string>,
options: InterpolatorOptions,
deleteValues = true,
): RenovateConfig {
const config = { ...config_ };
const { name } = options;
if (deleteValues) {
delete config[name];
}
for (const [key, value] of Object.entries(config)) {
if (isPlainObject(value) && key !== 'onboardingConfig') {
// @ts-expect-error -- type can't be narrowed
config[key] = replaceInterpolatedValuesInObject(
value,
input,
options,
deleteValues,
);
}
if (isString(value)) {
// @ts-expect-error -- type can't be narrowed
config[key] = replaceInterpolatedValuesInString(
key,
value,
input,
options,
);
}
if (isArray(value)) {
for (const [arrayIndex, arrayItem] of value.entries()) {
// v8 ignore else -- TODO: add test #40625
if (isPlainObject(arrayItem)) {
value[arrayIndex] = replaceInterpolatedValuesInObject(
arrayItem,
input,
options,
deleteValues,
);
} else if (isString(arrayItem)) {
value[arrayIndex] = replaceInterpolatedValuesInString(
key,
arrayItem,
input,
options,
);
}
}
}
}
return config;
}