Files
renovate/test/docs/documentation.spec.ts
Jamie Tanna 2a300e8d08 refactor(config): specify scopes for enabled and managerFilePatterns (#46683)
* 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>
2026-10-12 12:09:14 +00:00

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,
);
}
});
});
});
});