feat: command for linting Redocly config itself (#1491)

* feat: linting redocly config itself
This commit is contained in:
Dmytro Anansky
2024-03-22 17:31:03 +02:00
committed by GitHub
parent 617ccff63f
commit bd82bcdd93
21 changed files with 223 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
"@redocly/cli": patch
---
Added `check-config` command to validate a Redocly configuration file.

View File

@@ -0,0 +1,6 @@
apis:
main:
root: ./openapi.yaml
rules:
context:

View File

@@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`E2E check-config test with option: { dirName: 'invalid-config--lint-config-error', option: 'error' } 1`] = `
[1] .redocly.yaml:6:3 at #/rules/context
Property \`context\` is not expected here.
4 |
5 | rules:
6 | context:
| ^^^^^^^^
7 |
Error was generated by the configuration spec rule.
❌ Your config has 1 error.
`;

View File

@@ -0,0 +1,6 @@
apis:
main:
root: ./openapi.yaml
rules:
context:

View File

@@ -0,0 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`E2E check-config test with option: { dirName: 'invalid-config--lint-config-warn', option: 'warn' } 1`] = `
[1] .redocly.yaml:6:3 at #/rules/context
Property \`context\` is not expected here.
4 |
5 | rules:
6 | context:
| ^^^^^^^^
7 |
Warning was generated by the configuration spec rule.
⚠️ Your config has 1 warning.
`;

View File

@@ -0,0 +1,6 @@
apis:
main:
root: ./openapi.yaml
rules:
context:

View File

@@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`E2E check-config test with option: { dirName: 'invalid-config--no-option', option: null } 1`] = `
[1] .redocly.yaml:6:3 at #/rules/context
Property \`context\` is not expected here.
4 |
5 | rules:
6 | context:
| ^^^^^^^^
7 |
Error was generated by the configuration spec rule.
❌ Your config has 1 error.
`;

View File

@@ -0,0 +1,3 @@
apis:
main:
root: ./openapi.yaml

View File

@@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`E2E check-config run with config option 1`] = `
✅ Your config is valid.
`;

View File

@@ -0,0 +1,3 @@
apis:
main:
root: ./openapi.yaml

View File

@@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`E2E check-config test with option: { dirName: 'valid-config', option: null } 1`] = `
✅ Your config is valid.
`;

View File

@@ -54,6 +54,38 @@ describe('E2E', () => {
}); });
}); });
describe('check-config', () => {
const folderPathWithOptions: { dirName: string; option: string | null }[] = [
{ dirName: 'invalid-config--lint-config-warn', option: 'warn' },
{ dirName: 'invalid-config--lint-config-error', option: 'error' },
{ dirName: 'invalid-config--no-option', option: null },
{ dirName: 'valid-config', option: null },
];
test.each(folderPathWithOptions)('test with option: %s', (folderPathWithOptions) => {
const { dirName, option } = folderPathWithOptions;
const folderPath = join(__dirname, `check-config/${dirName}`);
const args = [...([option && `--lint-config=${option}`].filter(Boolean) as string[])];
const passedArgs = getParams('../../../packages/cli/src/index.ts', 'check-config', args);
const result = getCommandOutput(passedArgs, folderPath);
(expect(result) as any).toMatchSpecificSnapshot(join(folderPath, 'snapshot.js'));
});
test('run with config option', () => {
const dirName = 'valid-config-with-config-option';
const folderPath = join(__dirname, `check-config/${dirName}`);
const passedArgs = getParams('../../../packages/cli/src/index.ts', 'check-config', [
'--config=nested/redocly.yaml',
]);
const result = getCommandOutput(passedArgs, folderPath);
(expect(result) as any).toMatchSpecificSnapshot(join(folderPath, 'snapshot.js'));
});
});
describe('lint-config', () => { describe('lint-config', () => {
const lintOptions: { dirName: string; option: string | null; format?: string }[] = [ const lintOptions: { dirName: string; option: string | null; format?: string }[] = [
{ dirName: 'invalid-config--lint-config-off', option: 'off' }, { dirName: 'invalid-config--lint-config-off', option: 'off' },
@@ -454,6 +486,7 @@ describe('E2E', () => {
(<any>expect(result)).toMatchSpecificSnapshot(join(folderPath, 'snapshot.js')); (<any>expect(result)).toMatchSpecificSnapshot(join(folderPath, 'snapshot.js'));
}); });
}); });
describe('bundle with long description', () => { describe('bundle with long description', () => {
it('description should not be in folded mode', () => { it('description should not be in folded mode', () => {
const folderPath = join(__dirname, `bundle/bundle-description-long`); const folderPath = join(__dirname, `bundle/bundle-description-long`);

View File

@@ -12,6 +12,7 @@ type CLICommands =
| 'login' | 'login'
| 'logout' | 'logout'
| 'preview-docs' | 'preview-docs'
| 'check-config'
| 'push' | 'push'
| 'split' | 'split'
| 'stats' | 'stats'

View File

@@ -0,0 +1,39 @@
# `check-config`
## Introduction
Use this command to check that everything in a Redocly configuration file is valid and in the expected format.
Adding this check before using the configuration file with other commands can catch any problems at an early stage.
This command uses the same mechanism as our API linting to match a file against an expected data structure.
## Usage
```bash
redocly check-config
redocly check-config [--config=<path>]
```
## Options
| Option | Type | Description |
| ------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- |
| --config | string | Specify path to the [config file](#custom-configuration-file). |
| --lint-config | string | Specify the severity level for the configuration file. <br/> **Possible values:** `warn`, `error`. Default value is `error`. |
## Examples
### Default configuration file
By default, the CLI tool looks for the [Redocly configuration file](../configuration/index.md) in the current working directory.
```bash
redocly check-config
```
### Custom configuration file
Use the optional `--config` argument to provide an alternative path to a configuration file.
```bash
redocly check-config --config=./another/directory/config.yaml
```

View File

@@ -30,6 +30,8 @@
page: commands/split.md page: commands/split.md
- label: stats - label: stats
page: commands/stats.md page: commands/stats.md
- label: check-config
page: commands/check-config.md
- group: Guides - group: Guides
page: guides/index.md page: guides/index.md
items: items:

View File

@@ -24,6 +24,8 @@ import { performance } from 'perf_hooks';
import type { OutputFormat, ProblemSeverity, Document, RuleSeverity } from '@redocly/openapi-core'; import type { OutputFormat, ProblemSeverity, Document, RuleSeverity } from '@redocly/openapi-core';
import type { ResolvedRefMap } from '@redocly/openapi-core/lib/resolve'; import type { ResolvedRefMap } from '@redocly/openapi-core/lib/resolve';
import type { CommandOptions, Skips, Totals } from '../types'; import type { CommandOptions, Skips, Totals } from '../types';
import { getCommandNameFromArgs } from '../utils/getCommandNameFromArgs';
import { Arguments } from 'yargs';
export type LintOptions = { export type LintOptions = {
apis?: string[]; apis?: string[];
@@ -144,7 +146,9 @@ export function lintConfigCallback(
version, version,
}); });
printConfigLintTotals(fileTotals); const command = argv ? getCommandNameFromArgs(argv as unknown as Arguments) : undefined;
printConfigLintTotals(fileTotals, command);
if (fileTotals.errors > 0) { if (fileTotals.errors > 0) {
throw new ConfigValidationError(); throw new ConfigValidationError();

View File

@@ -499,6 +499,26 @@ yargs
commandWrapper(handleBundle)(argv); commandWrapper(handleBundle)(argv);
} }
) )
.command(
'check-config',
'Lint the Redocly configuration file.',
async (yargs) =>
yargs.option({
config: {
description: 'Path to the config file.',
type: 'string',
},
'lint-config': {
description: 'Severity level for config file linting.',
choices: ['warn', 'error'] as ReadonlyArray<RuleSeverity>,
default: 'error' as RuleSeverity,
},
}),
(argv) => {
process.env.REDOCLY_CLI_COMMAND = 'check-config';
commandWrapper()(argv);
}
)
.command( .command(
'login', 'login',
'Login to the Redocly API registry with an access token.', 'Login to the Redocly API registry with an access token.',

View File

@@ -38,8 +38,14 @@ export type CommandOptions =
| PreviewDocsOptions | PreviewDocsOptions
| BuildDocsArgv | BuildDocsArgv
| PushStatusOptions | PushStatusOptions
| VerifyConfigOptions
| PreviewProjectOptions; | PreviewProjectOptions;
export type VerifyConfigOptions = {
config?: string;
'lint-config'?: 'warning' | 'error' | 'off';
};
export type Skips = { export type Skips = {
'skip-rule'?: string[]; 'skip-rule'?: string[];
'skip-decorator'?: string[]; 'skip-decorator'?: string[];

View File

@@ -0,0 +1,5 @@
import { Arguments } from 'yargs';
export function getCommandNameFromArgs(argv: Arguments | undefined): string | number {
return argv?._?.[0] ?? '';
}

View File

@@ -352,7 +352,7 @@ export function printLintTotals(totals: Totals, definitionsCount: number) {
process.stderr.write('\n'); process.stderr.write('\n');
} }
export function printConfigLintTotals(totals: Totals): void { export function printConfigLintTotals(totals: Totals, command?: string | number): void {
if (totals.errors > 0) { if (totals.errors > 0) {
process.stderr.write( process.stderr.write(
red(`❌ Your config has ${totals.errors} ${pluralize('error', totals.errors)}.`) red(`❌ Your config has ${totals.errors} ${pluralize('error', totals.errors)}.`)
@@ -361,6 +361,8 @@ export function printConfigLintTotals(totals: Totals): void {
process.stderr.write( process.stderr.write(
yellow(`⚠️ Your config has ${totals.warnings} ${pluralize('warning', totals.warnings)}.\n`) yellow(`⚠️ Your config has ${totals.warnings} ${pluralize('warning', totals.warnings)}.\n`)
); );
} else if (command === 'check-config') {
process.stderr.write(green('✅ Your config is valid.\n'));
} }
} }

View File

@@ -11,7 +11,7 @@ import { lintConfigCallback } from './commands/lint';
import type { CommandOptions } from './types'; import type { CommandOptions } from './types';
export function commandWrapper<T extends CommandOptions>( export function commandWrapper<T extends CommandOptions>(
commandHandler: (argv: T, config: Config, version: string) => Promise<void> commandHandler?: (argv: T, config: Config, version: string) => Promise<void>
) { ) {
return async (argv: Arguments<T>) => { return async (argv: Arguments<T>) => {
let code: ExitCode = 2; let code: ExitCode = 2;
@@ -31,7 +31,9 @@ export function commandWrapper<T extends CommandOptions>(
telemetry = config.telemetry; telemetry = config.telemetry;
hasConfig = !config.styleguide.recommendedFallback; hasConfig = !config.styleguide.recommendedFallback;
code = 1; code = 1;
await commandHandler(argv, config, version); if (typeof commandHandler === 'function') {
await commandHandler(argv, config, version);
}
code = 0; code = 0;
} catch (err) { } catch (err) {
// Do nothing // Do nothing