mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-25 15:01:49 +00:00
2599cfac65
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>
545 lines
15 KiB
TypeScript
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';
|
|
}
|