mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-24 14:34:21 +00:00
2a300e8d08
* refactor(config): specify scopes for `enabled` and `managerFilePatterns` As part of #43020, the two options which name every manager - and in `enabled`'s case, every update type - can describe where they're valid through `scopes` instead, so they no longer need updating when a manager is added. This allows us to simplify the way that the parents are defined, and will now result in an improved error message i.e. ``` "managerFilePatterns" can't be used at the top level of a config, as it can only be used in a manager's config or in `customManagers`. ``` Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io> * fixup! refactor(config): specify scopes for `enabled` and `managerFilePatterns` getConfig() decides whether an option gets a top-level default by checking the raw option.parents for '.', which doesn't know about scopes. Giving `enabled` its top-level eligibility through `scopes` instead of a literal `parents: ['.']` entry meant getConfig() stopped giving it a default value at all, so `config.enabled` became undefined at the top level - breaking anything that reads it before a manager's own config is merged in, such as extract-fingerprint-config.ts. We can use getAllowedParents(), which already knows how to combine scopes and parents, instead of reading option.parents directly. Co-Authored-By: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io> --------- Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io>
393 lines
14 KiB
TypeScript
393 lines
14 KiB
TypeScript
import fs from 'fs-extra';
|
|
import { glob } from 'glob';
|
|
import { getOptions } from '../../lib/config/options/index.ts';
|
|
import { getAllowedParents } from '../../lib/config/options/scopes.ts';
|
|
import type { RenovateOptions, UpdateType } from '../../lib/config/types.ts';
|
|
import { regEx } from '../../lib/util/regex.ts';
|
|
import { templateHelperNames } from '../../lib/util/template/index.ts';
|
|
import { isMinimumReleaseAgeApplicable } from '../../lib/workers/repository/process/lookup/filter-checks.ts';
|
|
|
|
const options = getOptions();
|
|
|
|
function isTopLevelOption(option: RenovateOptions): boolean {
|
|
const parents = getAllowedParents(option);
|
|
return !parents || parents.includes('.');
|
|
}
|
|
const markdownGlob = '{docs,lib}/**/*.md';
|
|
|
|
describe('docs/documentation', () => {
|
|
it('has no invalid links', async () => {
|
|
const markdownFiles = await glob(markdownGlob);
|
|
|
|
await Promise.all(
|
|
markdownFiles.map(async (markdownFile) => {
|
|
const markdownText = await fs.readFile(markdownFile, 'utf8');
|
|
expect(markdownText).not.toMatch(regEx(/\.md\/#/));
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe('website-documentation', () => {
|
|
async function getConfigOptionSubHeaders(
|
|
file: string,
|
|
configOption: string,
|
|
): Promise<string[]> {
|
|
const subHeadings = [];
|
|
const content = await fs.readFile(`docs/usage/${file}`, 'utf8');
|
|
const reg = regEx(`##\\s\`?${configOption}\`?[\\s\\S]+?\n##\\s`);
|
|
const match = reg.exec(content);
|
|
const subHeadersMatch = match?.[0]?.matchAll(
|
|
/\n###\s`?(?<child>[\w.]+)`?\n/g,
|
|
);
|
|
if (subHeadersMatch) {
|
|
for (const subHeaderStr of subHeadersMatch) {
|
|
if (subHeaderStr?.groups?.child) {
|
|
subHeadings.push(subHeaderStr.groups.child);
|
|
}
|
|
}
|
|
}
|
|
return subHeadings;
|
|
}
|
|
|
|
describe('docs/usage/configuration-options.md', () => {
|
|
async function getConfigHeaders(file: string): Promise<string[]> {
|
|
const content = await fs.readFile(`docs/usage/${file}`, 'utf8');
|
|
const matches = content.match(/\n## (?:.*?)\n/g) ?? [];
|
|
return matches
|
|
.map((match) =>
|
|
match.substring(4, match.length - 1).replace(/^`|`$/g, ''),
|
|
)
|
|
.filter((header) => header !== 'managerFilePatterns')
|
|
.filter(
|
|
(header) => header !== 'Locations for configuration filenames',
|
|
);
|
|
}
|
|
|
|
function getRequiredConfigOptions(): string[] {
|
|
return (
|
|
options
|
|
.filter((option) => !option.globalOnly)
|
|
// Only include top-level options, which are valid anywhere, or are valid at the root ('.') through their parents or scopes.
|
|
.filter((option) => isTopLevelOption(option))
|
|
.filter((option) => !option.autogenerated)
|
|
.map((option) => option.name)
|
|
.sort()
|
|
);
|
|
}
|
|
|
|
it('has doc headers sorted alphabetically', async () => {
|
|
await expect(
|
|
getConfigHeaders('configuration-options.md'),
|
|
).resolves.toEqual(
|
|
(await getConfigHeaders('configuration-options.md')).sort(),
|
|
);
|
|
});
|
|
|
|
it('has headers for every required option', async () => {
|
|
await expect(
|
|
getConfigHeaders('configuration-options.md'),
|
|
).resolves.toEqual(getRequiredConfigOptions());
|
|
});
|
|
|
|
function getPostUpdateOptionsValues(): Set<string> {
|
|
const option = options.find((o) => o.name === 'postUpdateOptions');
|
|
return new Set(option?.allowedValues ?? []);
|
|
}
|
|
|
|
async function getConfigSubHeaders(file: string): Promise<string[]> {
|
|
const postUpdateValues = getPostUpdateOptionsValues();
|
|
const content = await fs.readFile(`docs/usage/${file}`, 'utf8');
|
|
const matches = content.match(/\n### (?:.*?)\n/g) ?? [];
|
|
return matches
|
|
.map((match) =>
|
|
match.substring(5, match.length - 1).replace(/^`|`$/g, ''),
|
|
)
|
|
.filter((header) => !postUpdateValues.has(header))
|
|
.sort();
|
|
}
|
|
|
|
function getRequiredConfigSubOptions(): string[] {
|
|
return (
|
|
options
|
|
.filter((option) => option.stage !== 'global')
|
|
.filter((option) => !option.globalOnly)
|
|
// Only include true sub-options: options which name their parents, and aren't valid at the root ('.').
|
|
.filter(
|
|
(option) => option.parents?.length && !isTopLevelOption(option),
|
|
)
|
|
.filter((option) => option.name !== 'managerFilePatterns')
|
|
.flatMap((option) =>
|
|
(option.parents ?? [])
|
|
.filter((parent) => parent !== '.')
|
|
.map((parent) => `${parent}.${option.name}`),
|
|
)
|
|
.sort()
|
|
);
|
|
}
|
|
|
|
function getParentNames(): Set<string> {
|
|
const childrens = options
|
|
.filter((option) => option.stage !== 'global')
|
|
.filter((option) => !option.globalOnly)
|
|
.filter((option) => option.parents);
|
|
|
|
const parentNames = new Set<string>();
|
|
for (const children of childrens) {
|
|
const parents = children.parents ?? [];
|
|
for (const parent of parents) {
|
|
parentNames.add(parent);
|
|
}
|
|
}
|
|
|
|
return parentNames;
|
|
}
|
|
|
|
it('has headers for every required sub-option', async () => {
|
|
await expect(
|
|
getConfigSubHeaders('configuration-options.md'),
|
|
).resolves.toEqual(getRequiredConfigSubOptions());
|
|
});
|
|
|
|
it.each([...getParentNames()])(
|
|
'%s has sub-headers sorted alphabetically',
|
|
async (parentName: string) => {
|
|
await expect(
|
|
getConfigOptionSubHeaders('configuration-options.md', parentName),
|
|
).resolves.toEqual(
|
|
(
|
|
await getConfigOptionSubHeaders(
|
|
'configuration-options.md',
|
|
parentName,
|
|
)
|
|
).sort(),
|
|
);
|
|
},
|
|
);
|
|
|
|
it('has a sorted header for every postUpdateOptions value', async () => {
|
|
const postUpdateHeaders = await getConfigOptionSubHeaders(
|
|
'configuration-options.md',
|
|
'postUpdateOptions',
|
|
);
|
|
const postUpdateOption = options.find(
|
|
(o) => o.name === 'postUpdateOptions',
|
|
);
|
|
const allowedValues = [
|
|
...(postUpdateOption?.allowedValues ?? []),
|
|
].sort();
|
|
expect(postUpdateHeaders).toEqual(allowedValues);
|
|
});
|
|
});
|
|
|
|
describe('docs/usage/self-hosted-configuration.md', () => {
|
|
async function getSelfHostedHeaders(file: string): Promise<string[]> {
|
|
const content = await fs.readFile(`docs/usage/${file}`, 'utf8');
|
|
const matches = content.match(/\n## (?:.*?)\n/g) ?? [];
|
|
return matches.map((match) =>
|
|
match.substring(4, match.length - 1).replace(/^`|`$/g, ''),
|
|
);
|
|
}
|
|
|
|
// Sub-options (e.g. `hostRules.allowInternal`) are only valid in self-hosted
|
|
// config, so their parent (e.g. `hostRules`) needs its own top-level header
|
|
// here even though the parent option itself isn't `globalOnly`.
|
|
function getSelfHostedParentNames(): Set<string> {
|
|
const childrens = options
|
|
.filter((option) => option.globalOnly)
|
|
.filter(
|
|
(option) =>
|
|
option.parents &&
|
|
option.parents.length > 0 &&
|
|
!option.parents.includes('.'),
|
|
);
|
|
|
|
const parentNames = new Set<string>();
|
|
for (const children of childrens) {
|
|
for (const parent of children.parents ?? []) {
|
|
parentNames.add(parent);
|
|
}
|
|
}
|
|
|
|
return parentNames;
|
|
}
|
|
|
|
function getRequiredSelfHostedOptions(): string[] {
|
|
const topLevelOptions = options
|
|
.filter((option) => option.globalOnly)
|
|
// Only include top-level options, which have no parents (implicit root) or explicitly define the
|
|
// root ('.') as their parent.
|
|
.filter(
|
|
(option) =>
|
|
!option.parents ||
|
|
option.parents.length === 0 ||
|
|
option.parents.includes('.'),
|
|
)
|
|
.map((option) => option.name);
|
|
|
|
return [
|
|
...new Set([...topLevelOptions, ...getSelfHostedParentNames()]),
|
|
].sort();
|
|
}
|
|
|
|
async function getSelfHostedSubHeaders(file: string): Promise<string[]> {
|
|
const content = await fs.readFile(`docs/usage/${file}`, 'utf8');
|
|
const matches = content.matchAll(/\n###\s`?(?<child>[\w.]+)`?\n/g);
|
|
return [...matches]
|
|
.map((match) => match.groups?.child)
|
|
.filter((child): child is string => !!child)
|
|
.sort();
|
|
}
|
|
|
|
function getRequiredSelfHostedSubOptions(): string[] {
|
|
return (
|
|
options
|
|
.filter((option) => option.globalOnly)
|
|
// Only include true sub-options: options which have parents but none of those are explicitly the root ('.').
|
|
.filter(
|
|
(option) =>
|
|
option.parents &&
|
|
option.parents.length > 0 &&
|
|
!option.parents.includes('.'),
|
|
)
|
|
.flatMap((option) =>
|
|
(option.parents ?? [])
|
|
.filter((parent) => parent !== '.')
|
|
.map((parent) => `${parent}.${option.name}`),
|
|
)
|
|
.sort()
|
|
);
|
|
}
|
|
|
|
it('has headers sorted alphabetically', async () => {
|
|
await expect(
|
|
getSelfHostedHeaders('self-hosted-configuration.md'),
|
|
).resolves.toEqual(
|
|
(await getSelfHostedHeaders('self-hosted-configuration.md')).sort(),
|
|
);
|
|
});
|
|
|
|
it('has headers for every required option', async () => {
|
|
await expect(
|
|
getSelfHostedHeaders('self-hosted-configuration.md'),
|
|
).resolves.toEqual(getRequiredSelfHostedOptions());
|
|
});
|
|
|
|
it('has headers for every required sub-option', async () => {
|
|
await expect(
|
|
getSelfHostedSubHeaders('self-hosted-configuration.md'),
|
|
).resolves.toEqual(getRequiredSelfHostedSubOptions());
|
|
});
|
|
|
|
it.each([...getSelfHostedParentNames()])(
|
|
'%s has sub-headers sorted alphabetically',
|
|
async (parentName: string) => {
|
|
await expect(
|
|
getConfigOptionSubHeaders(
|
|
'self-hosted-configuration.md',
|
|
parentName,
|
|
),
|
|
).resolves.toEqual(
|
|
(
|
|
await getConfigOptionSubHeaders(
|
|
'self-hosted-configuration.md',
|
|
parentName,
|
|
)
|
|
).sort(),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('docs/usage/self-hosted-experimental.md', () => {
|
|
async function getSelfHostedExperimentalConfigHeaders(
|
|
file: string,
|
|
): Promise<string[]> {
|
|
const content = await fs.readFile(`docs/usage/${file}`, 'utf8');
|
|
const matches = content.match(/\n## (?:.*?)\n/g) ?? [];
|
|
return matches.map((match) => match.substring(4, match.length - 1));
|
|
}
|
|
|
|
it('has headers sorted alphabetically', async () => {
|
|
await expect(
|
|
getSelfHostedExperimentalConfigHeaders('self-hosted-experimental.md'),
|
|
).resolves.toEqual(
|
|
(
|
|
await getSelfHostedExperimentalConfigHeaders(
|
|
'self-hosted-experimental.md',
|
|
)
|
|
).sort(),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('docs/usage/templates.md', async () => {
|
|
async function getAdditionalHandlebarsHelpersHeaders(): Promise<
|
|
string[]
|
|
> {
|
|
const content = await fs.readFile(`docs/usage/templates.md`, 'utf8');
|
|
const matches = content.match(/\n### (?:.*?)\n/g) ?? [];
|
|
return matches.map((match) => match.substring(5, match.length - 1));
|
|
}
|
|
|
|
const additionalHandlebarsHelpers =
|
|
await getAdditionalHandlebarsHelpersHeaders();
|
|
|
|
it('has headers sorted alphabetically', () => {
|
|
expect(additionalHandlebarsHelpers).toEqual(
|
|
additionalHandlebarsHelpers.toSorted(),
|
|
);
|
|
});
|
|
|
|
it('documents all added Handlebars helpers', () => {
|
|
expect(additionalHandlebarsHelpers).toEqual(
|
|
templateHelperNames.toSorted(),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('docs/usage/key-concepts/minimum-release-age.md', () => {
|
|
const supportEmoji: Record<string, boolean> = {
|
|
'✅': true,
|
|
'🟡': true,
|
|
'❌': false,
|
|
};
|
|
|
|
async function getUpdateTypeSupportTable(): Promise<
|
|
Record<string, boolean>
|
|
> {
|
|
const content = await fs.readFile(
|
|
'docs/usage/key-concepts/minimum-release-age.md',
|
|
'utf8',
|
|
);
|
|
// RE2 (renovate's regex engine) doesn't support lookahead, so bound
|
|
// the section with plain index lookups instead of `(?=\n### )`.
|
|
const start = regEx(/### Which update types take/).exec(content)?.index;
|
|
let section: string | undefined;
|
|
if (start !== undefined) {
|
|
const end = content.indexOf('\n### ', start + 1);
|
|
section = content.slice(start, end === -1 ? undefined : end);
|
|
}
|
|
const rows =
|
|
section?.matchAll(
|
|
/^\|\s*`(?<updateType>\w+)`\s*\|\s*(?<emoji>[^\s|]+)\s*\|/gm,
|
|
) ?? [];
|
|
const table: Record<string, boolean> = {};
|
|
for (const row of rows) {
|
|
const { updateType, emoji } = row.groups!;
|
|
table[updateType] = supportEmoji[emoji];
|
|
}
|
|
return table;
|
|
}
|
|
|
|
it('matches isMinimumReleaseAgeApplicable() for every documented update type', async () => {
|
|
const table = await getUpdateTypeSupportTable();
|
|
for (const [updateType, docsSupport] of Object.entries(table)) {
|
|
expect(isMinimumReleaseAgeApplicable(updateType as UpdateType)).toBe(
|
|
docsSupport,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|