Files
Jamie Tanna 2599cfac65 refactor(config): add generic suggestedValues hint (#46959)
As part of follow-up changes, we'll want to suggest values for options
other than `extends` - such as the managers, datasources and
categories a `match*` option takes, and the host types a `hostRules`
entry's `hostType` takes - which need the same `anyOf: [{enum}, items]`
shape we already build for suggesting presets.

We can first prefactor the way that we propose suggested values for
`extends` into a shape that we can re-use for other types, extracting a
`suggestValues` helper function, and building both the array and scalar
cases generically in `createSingleConfig`.

The scalar case isn't exercised by any option yet - the next two
commits each introduce one - so it's marked as such for coverage.

Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io>
2026-10-19 16:40:37 +00:00

545 lines
15 KiB
TypeScript

import is from '@sindresorhus/is';
import stringify from 'json-stringify-pretty-compact';
import { getConfigFileNames } from '../../lib/config/app-strings.ts';
import { MigrationsService } from '../../lib/config/migrations/index.ts';
import { getEnvName } from '../../lib/config/options/env.ts';
import { getOptions } from '../../lib/config/options/index.ts';
import {
allManagersList,
getManagers,
} from '../../lib/modules/manager/index.ts';
import { packageCacheNamespaces } from '../../lib/util/cache/package/namespaces.ts';
import {
getSupportedToolNames,
getToolConfig,
} from '../../lib/util/exec/containerbase.ts';
import type { ConstraintDefinition } from '../../lib/util/exec/types.ts';
import {
additionalConstraintDefinitions,
toolDefinitionDocumentation,
} from '../../lib/util/exec/types.ts';
import { coerceObject } from '../../lib/util/object.ts';
import { getCliName } from '../../lib/workers/global/config/parse/cli.ts';
import { convertedExperimentalEnvVars } from '../../lib/workers/global/config/parse/env.ts';
import { readFile, updateFile } from '../utils/index.ts';
import { formatCell, indexMarkdown, replaceContent } from './utils.ts';
const options = getOptions();
const managers = new Set(allManagersList);
const previousNames: ReadonlyMap<string, string[]> = (() => {
const map = new Map<string, string[]>();
function add(optionName: string, previousName: string): void {
const existing = map.get(optionName);
if (existing) {
existing.push(previousName);
} else {
map.set(optionName, [previousName]);
}
}
for (const [oldName, newName] of MigrationsService.renamedProperties) {
add(newName, oldName);
}
for (const [oldEnvVar, { optionName }] of convertedExperimentalEnvVars) {
add(optionName, oldEnvVar);
}
return map;
})();
/**
* Merge string arrays one by one
* Example: let arr1 = ['a','b','c'], arr2 = ['1','2','3','4','5']
* merge(arr1,arr2) = ['a','1','b','2','c','3','4','5']
* @param array1
* @param array2
*/
function merge(array1: string[], array2: string[]): string[] {
const arr1 = [...array1];
const arr2 = [...array2];
const merged: string[] = [];
for (const str1 of arr1) {
merged.push(str1);
const str2 = arr2.pop();
if (str2 !== undefined) {
merged.push(str2);
}
}
return merged.concat(arr2);
}
function indent(
strings: TemplateStringsArray,
...keys: (string | number | boolean)[]
): string {
const indent = ' ';
const strs = [...strings];
let amount = 0;
// validate input
if (typeof keys[0] === 'number' && strings[0] === '') {
amount = keys.shift() as number;
strs.shift();
}
return indent.repeat(amount) + merge(strs, keys.map(String)).join('');
}
function buildHtmlTable(data: string[][]): string {
// skip empty tables
if (data.length < 2) {
return '';
}
let table = `<table>\n`;
for (const [rowIndex, row] of data.entries()) {
if (rowIndex === 0) {
table += indent`${1}<thead>\n`;
}
if (rowIndex === 1) {
table += indent`${1}</thead>\n` + indent`${1}<tbody>\n`;
}
table += indent`${2}<tr>\n`;
for (let colIndex = 0; colIndex < row.length; colIndex++) {
const col = row[colIndex];
if (rowIndex === 0) {
// header row
table += indent`${3}<th>${col}</th>\n`;
continue;
}
const cellHtml = formatCell(row, colIndex);
table += `${indent`${3}${cellHtml}`}${`${col}`.endsWith('\n') ? indent`${3}` : ''}\n`;
}
table += indent`${2}</tr>\n`;
}
table += indent`${1}</tbody>\n</table>\n`;
return table;
}
function genTable(obj: [string, string][], type: string, def: any): string {
const data = [['Name', 'Value']];
const name = obj[0][1];
const ignoredKeys = [
'name',
'description',
'default',
'stage',
'allowString',
'admin',
'globalOnly',
'experimental',
'experimentalDescription',
'experimentalIssues',
'advancedUse',
'deprecationMsg',
'patternMatch',
'properties',
'requiresCheckAtTrustBoundary',
'suggestedValues',
];
obj.forEach(([key, val]) => {
const el = [key, val];
if (key === 'cli' && !val) {
ignoredKeys.push('cli');
}
if (key === 'env' && !val) {
ignoredKeys.push('env');
}
if (
!ignoredKeys.includes(el[0]) ||
(el[0] === 'default' &&
(typeof el[1] !== 'object' || ['array', 'object'].includes(type)) &&
name !== 'prBody')
) {
if (type === 'string' && el[0] === 'default') {
el[1] = `<code>"${el[1]}"</code>`;
}
if (
(type === 'boolean' && el[0] === 'default') ||
el[0] === 'cli' ||
el[0] === 'env'
) {
el[1] = `<code>${el[1]}</code>`;
}
if (
// objects and arrays should be printed in JSON notation
((type === 'object' || type === 'array') &&
(el[0] === 'default' || el[0] === 'additionalProperties')) ||
// enum values for `allowedValues` should be printed in JSON notation
el[0] === 'allowedValues' ||
// previous names should be printed in JSON notation
el[0] === 'previouslyKnownAs'
) {
// only show array and object defaults if they are not null and are not empty
if (Object.keys(el[1] ?? []).length === 0) {
return;
}
el[1] = `\n\`\`\`json\n${stringify(el[1], { indent: 2 })}\n\`\`\`\n`;
}
data.push(el);
}
});
if (data.find((k) => k[0] === 'default') === undefined) {
if (type === 'array') {
data.push(['default', '<code>[]</code>']);
}
if (type === 'string' && def === undefined) {
data.push(['default', '<code>null</code>']);
}
if (type === 'boolean' && def === undefined) {
data.push(['default', '<code>true</code>']);
}
if (type === 'boolean' && def === null) {
data.push(['default', '<code>null</code>']);
}
}
return buildHtmlTable(data);
}
function stringifyArrays(el: Record<string, any>): void {
const ignoredKeys = [
'allowedValues',
'default',
'experimentalIssues',
'previouslyKnownAs',
];
for (const [key, value] of Object.entries(el)) {
if (!ignoredKeys.includes(key) && Array.isArray(value)) {
el[key] = value.join(', ');
}
}
}
function genExperimentalMsg(el: Record<string, any>): string {
const ghIssuesUrl = 'https://github.com/renovatebot/renovate/issues/';
let warning = '\n!!! warning "This feature is flagged as experimental"\n';
if (el.experimentalDescription) {
warning += indent`${1}${el.experimentalDescription}`;
} else {
warning += indent`${1}Experimental features might be changed or even removed at any time.`;
}
const issues = el.experimentalIssues ?? [];
if (issues.length > 0) {
warning += `<br>To track this feature visit the following GitHub ${
issues.length > 1 ? 'issues' : 'issue'
} `;
warning += `${issues
.map((issue: number) => `[#${issue}](${ghIssuesUrl}${issue})`)
.join(', ')}.`;
}
return `${warning}\n`;
}
function genTemplatingMsg(): string {
return `\n!!! tip "This option supports Renovate's template syntax"\n${indent`${1}See [templates](templates.md) for available variables and helpers.`}\n`;
}
function genDeprecationMsg(el: Record<string, any>): string {
let warning = '\n!!! warning "This feature has been deprecated"\n';
if (el.deprecationMsg) {
warning += indent`${1}${el.deprecationMsg}`;
}
return `${warning}\n`;
}
function generateLockFileTable(): string {
const allManagers = getManagers();
const rows: { name: string; lockFiles: string[] }[] = [];
for (const [name, definition] of allManagers) {
if (
definition.supportsLockFileMaintenance &&
definition.lockFileNames?.length
) {
rows.push({ name, lockFiles: definition.lockFileNames });
}
}
rows.sort((a, b) => a.name.localeCompare(b.name));
let table = '\n| Manager | Lockfile |\n';
table += '| :-- | :-- |\n';
for (const row of rows) {
const lockFiles = row.lockFiles.map((f) => `\`${f}\``).join(', ');
table += `| \`${row.name}\` | ${lockFiles} |\n`;
}
return table;
}
function generateCacheNamespacesList(): string {
const namespaces = packageCacheNamespaces
.filter((ns) => ns !== '_test-namespace')
.slice()
.sort();
let list = '\n';
for (const ns of namespaces) {
list += `- \`${ns}\`\n`;
}
list += '\n';
return list;
}
function generateInheritConfigSupportList(): string {
const inheritable = options
.filter((option) => option.inheritConfigSupport)
.map((option) => {
const parent = option.parents?.find((p) => p !== '.');
return parent ? `${parent}.${option.name}` : option.name;
})
.sort((a, b) => a.localeCompare(b));
let list = '\n';
for (const fullName of inheritable) {
list += `- \`${fullName}\`\n`;
}
list += '\n';
return list;
}
function generateStatusCheckWhenTable(): string {
const option = options.find((o) => o.name === 'statusCheckWhen');
const defaults = coerceObject<Record<string, string>>(option?.default);
const reasoning: Record<string, string> = {
artifactError: 'Legacy behavior — only reports artifact failures',
configValidation: 'Always reports validation pass/fail',
mergeConfidence: 'Always reports confidence level',
minimumReleaseAge: 'Always reports stability status',
};
const keys = Object.keys(defaults).sort();
let table = '\n| Key | Default | Reasoning |\n';
table += '| :-- | :-- | :-- |\n';
for (const key of keys) {
table += `| \`${key}\` | \`${defaults[key]}\` | ${reasoning[key] ?? ''} |\n`;
}
return table;
}
function generateConfigFileNames(): string {
// TODO #10682 #10651 make sure that we include `getConfigFileNames(platformId)`
const filenames = getConfigFileNames();
const all = Array.from(new Set(filenames))
// remove `package.json`, as we'll write a custom line item for it
.filter((v) => v !== 'package.json');
let output = '';
for (const f of all) {
output += `1. \`${f}\`\n`;
}
output += '1. `package.json` _(within a `"renovate"` section)_\n';
return output;
}
function generateToolsForConstraints(): string {
let output = '| Tool | Additional Information | Versioning | Datasource |\n';
output += '| --- | --- | --- | --- |\n';
for (const toolName of getSupportedToolNames()) {
const toolConfig = getToolConfig(toolName);
if (!toolConfig) {
continue;
}
const def = toolDefinitionDocumentation.find((t) => t.name === toolName);
// Newlines in the Markdown-rendered table will break table rendering
const desc = def?.description?.replaceAll('\n', '<br>') ?? '';
output += `| \`${toolName}\` | ${desc} | [${toolConfig.versioning}](./modules/versioning/${toolConfig.versioning}/index.md) | [${toolConfig.datasource}](./modules/datasource/${toolConfig.datasource}/index.md) |\n`;
}
return output;
}
function generateAdditionalConstraints(): string {
let output = '| Constraint | Additional Information |\n';
output += '| --- | --- |\n';
for (const {
name,
description,
} of additionalConstraintDefinitions as readonly ConstraintDefinition[]) {
// Newlines in the Markdown-rendered table will break table rendering
const desc = description?.replaceAll('\n', '<br>') ?? '';
output += `| \`${name}\` | ${desc} |\n`;
}
return output;
}
function generateToolsForInstallTools(): string {
let output = '';
for (const tool of getSupportedToolNames()) {
output += `- \`${tool}\`\n`;
}
return output;
}
export async function generateConfig(
dist: string,
globalOnly = false,
): Promise<void> {
let configFile = `configuration-options.md`;
if (globalOnly) {
configFile = `self-hosted-configuration.md`;
}
const configOptionsRaw = (await readFile(`docs/usage/${configFile}`)).split(
'\n',
);
const indexed = indexMarkdown(configOptionsRaw);
options
.filter(
(option) =>
!!option.globalOnly === globalOnly && !managers.has(option.name),
)
.forEach((option) => {
// TODO: fix types (#22198,#9610)
const el: Record<string, any> = { ...option };
// Child options are indexed as "parent.optionName"; collect all matching keys
let lookupKeys: string[] = [];
for (const parent of option.parents ?? []) {
if (parent !== '.') {
const key = `${parent}.${option.name}`;
if (indexed[key]) {
lookupKeys.push(key);
}
}
}
// Fall back to plain name for top-level ## options (e.g. enabled, managerFilePatterns)
if (lookupKeys.length === 0) {
if (!indexed[option.name]) {
throw new Error(
`Config option "${option.name}" is missing an entry in ${configFile}`,
);
}
lookupKeys = [option.name];
}
el.cli = getCliName(option);
el.env = getEnvName(option);
const prevNames = previousNames.get(option.name);
if (prevNames) {
el.previouslyKnownAs = prevNames;
}
stringifyArrays(el);
for (const key of lookupKeys) {
const [headerIndex] = indexed[key];
let sectionContent = `\n${option.description}\n\n${genTable(Object.entries(el), option.type, option.default)}`;
if (el.supportsTemplating) {
sectionContent += genTemplatingMsg();
}
configOptionsRaw[headerIndex] += sectionContent;
if (el.experimental) {
configOptionsRaw[headerIndex] += genExperimentalMsg(el);
}
if (el.advancedUse) {
configOptionsRaw[headerIndex] += generateAdvancedUse();
}
if (is.nonEmptyString(el.deprecationMsg)) {
configOptionsRaw[headerIndex] += genDeprecationMsg(el);
}
}
});
let content = configOptionsRaw.join('\n');
if (globalOnly) {
content = replaceContent(
content,
generateCacheNamespacesList(),
'<!-- Autogenerate cache-namespaces -->',
);
}
if (globalOnly) {
content = replaceContent(
content,
generateInheritConfigSupportList(),
'<!-- Autogenerate inheritConfigSupport-list -->',
);
}
if (!globalOnly) {
content = replaceContent(
content,
generateLockFileTable(),
'<!-- lock-file-maintenance-table-start -->',
);
}
if (!globalOnly) {
content = replaceContent(
content,
generateConfigFileNames(),
'<!-- config-filenames-begin -->',
);
}
if (!globalOnly) {
content = replaceContent(
content,
generateToolsForConstraints(),
'<!-- constraints-tools-begin -->',
);
}
if (!globalOnly) {
content = replaceContent(
content,
generateAdditionalConstraints(),
'<!-- additional-constraints-begin -->',
);
}
if (!globalOnly) {
content = replaceContent(
content,
generateToolsForInstallTools(),
'<!-- installTools-tools-begin -->',
);
}
if (!globalOnly) {
content = replaceContent(
content,
generateStatusCheckWhenTable(),
'<!-- status-check-when-defaults-begin -->',
);
}
await updateFile(`${dist}/${configFile}`, content);
}
function generateAdvancedUse(): string {
return '!!! warning\n' + ' For advanced use only! Use at your own risk!\n';
}