Repository: Redocly/redocly-cli
Pull request #860 (2022-09-08 14:56:10)

feat: new format option 'summary'
## What/Why/How?

New format option: `summary`:

![image](https://user-images.githubusercontent.com/3975738/189155355-70dbe12b-d7d5-407f-8520-e968419525c8.png)

Also, fixed json output.

## Testing

Covered with tests.

## Screenshots (optional)

## Check yourself

- [x] Code is linted
- [ ] Tested with redoc/reference-docs/workflows
- [ ] All new/updated code is covered with tests

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


github-actions[bot]: <!-- jest coverage report action for options with hash 3339ae53a38c44382c0fb4491bdb282d -->

## Coverage report 



| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category   | Percentage | Covered / Total |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------- | :--------- | :-------------: |
|                                                                           🟡                                                                           | Statements | 72.85%     |    3163/4342    |
|                                                                           🟡                                                                           | Branches   | 64.45%     |    1697/2633    |
|                                                                           🟡                                                                           | Functions  | 64.76%     |     522/806     |
|                                                                           🟡                                                                           | Lines      | 72.79%     |    2940/4039    |


<details><summary>Show files with reduced coverage 🔻</summary>
<br/>

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File                                                        | Statements                                                  | Branches                                                       | Functions | Lines                                                          |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------- | :---------------------------------------------------------- | :------------------------------------------------------------- | :-------- | :------------------------------------------------------------- |
|                                                                           🟡                                                                           | <div title="cli/src/commands/lint.ts">`...` / lint.ts</div> | <div title="Base coverage is: 80%">77.19% (-2.81% 🔻)</div> | <div title="Base coverage is: 81.25%">76.47% (-4.78% 🔻)</div> | 60%       | <div title="Base coverage is: 81.48%">78.57% (-2.91% 🔻)</div> |

</details>

## Test suite run success
484 tests passing in 84 suites.

<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from 608a148f8dfae3954eccf2088d7a5b9193644299</p>


Patch:
diff --git a/docs/commands/lint.md b/docs/commands/lint.md
index 1786d13d..6f8748f8 100644
--- a/docs/commands/lint.md
+++ b/docs/commands/lint.md
@@ -27,7 +27,7 @@ Option | Type | Description
 apis | array | Array of API definition filenames that need to be linted. See [the Apis section](#apis) for more options.
 --config | string | Specify path to the [configuration file](#custom-configuration-file).
 --extends | array | [Extend a specific configuration](#extend-configuration) (defaults or config file settings).
---format | string | Format for the output.<br />**Possible values:** `codeframe`, `stylish`, `json`, `checkstyle`, `codeclimate`.
+--format | string | Format for the output.<br />**Possible values:** `codeframe`, `stylish`, `json`, `checkstyle`, `codeclimate`, `summary`.
 --generate-ignore-file | boolean | [Generate ignore file](#generate-ignore-file).
 --help | boolean | Show help.
 --lint-config | string | Specify the severity level for the configuration file. <br/> **Possible values:** `warn`, `error`, `off`. Default value is `warn`.
diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts
index 8281ca19..5ac1389b 100644
--- a/packages/cli/src/commands/lint.ts
+++ b/packages/cli/src/commands/lint.ts
@@ -127,6 +127,11 @@ function lintConfigCallback(argv: LintOptions, version: string) {
     return;
   }
 
+  if (argv.format === 'json') {
+    // we can't print config lint results as it will break json output
+    return;
+  }
+
   return async (config: RawConfig) => {
     const { 'max-problems': maxProblems, format } = argv;
     const configPath = findConfig(argv.config) || '';
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 75ba478f..0ee0a6b6 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -152,6 +152,7 @@ yargs
             'json',
             'checkstyle',
             'codeclimate',
+            'summary',
           ] as ReadonlyArray<OutputFormat>,
           default: 'codeframe' as OutputFormat,
         },
diff --git a/packages/core/src/format/format.ts b/packages/core/src/format/format.ts
index b845e13c..d0670baa 100644
--- a/packages/core/src/format/format.ts
+++ b/packages/core/src/format/format.ts
@@ -44,7 +44,13 @@ function severityToNumber(severity: ProblemSeverity) {
   return severity === 'error' ? 1 : 2;
 }
 
-export type OutputFormat = 'codeframe' | 'stylish' | 'json' | 'checkstyle' | 'codeclimate';
+export type OutputFormat =
+  | 'codeframe'
+  | 'stylish'
+  | 'json'
+  | 'checkstyle'
+  | 'codeclimate'
+  | 'summary';
 
 export function getTotals(problems: (NormalizedProblem & { ignored?: boolean })[]): Totals {
   let errors = 0;
@@ -143,6 +149,9 @@ export function formatProblems(
     case 'codeclimate':
       outputForCodeClimate();
       break;
+    case 'summary':
+      formatSummary(problems);
+      break;
   }
 
   if (totalProblems - ignoredProblems > maxProblems) {
@@ -257,6 +266,26 @@ export function formatProblems(
   }
 }
 
+function formatSummary(problems: NormalizedProblem[]): void {
+  const counts: Record<string, { count: number; severity: ProblemSeverity }> = {};
+  for (const problem of problems) {
+    counts[problem.ruleId] = counts[problem.ruleId] || { count: 0, severity: problem.severity };
+    counts[problem.ruleId].count++;
+  }
+  const sorted = Object.entries(counts).sort(([, a], [, b]) => {
+    const severityDiff = severityToNumber(a.severity) - severityToNumber(b.severity);
+    return severityDiff || b.count - a.count;
+  });
+
+  for (const [ruleId, info] of sorted) {
+    const color = COLORS[info.severity];
+    const severityName = color(SEVERITY_NAMES[info.severity].toLowerCase().padEnd(7));
+    logger.info(`${severityName} ${ruleId}: ${info.count}\n`);
+  }
+
+  logger.info('\n');
+}
+
 function formatFrom(cwd: string, location?: LocationObject) {
   if (!location) return '';
   const relativePath = path.relative(cwd, location.source.absoluteRef);


Test patch:
diff --git a/__tests__/commands.test.ts b/__tests__/commands.test.ts
index 1bcc5515..22b945ae 100644
--- a/__tests__/commands.test.ts
+++ b/__tests__/commands.test.ts
@@ -31,7 +31,7 @@ describe('E2E', () => {
   });
 
   describe('lint-config', () => {
-    const lintOptions: { dirName: string; option: string | null }[] = [
+    const lintOptions: { dirName: string; option: string | null; format?: string }[] = [
       { dirName: 'invalid-config--lint-config-off', option: 'off' },
       { dirName: 'invalid-config--lint-config-warn', option: 'warn' },
       { dirName: 'invalid-config--lint-config-error', option: 'error' },
@@ -39,16 +39,22 @@ describe('E2E', () => {
       { dirName: 'invalid-config--no-option', option: null },
       { dirName: 'invalid-config-assertation-name', option: 'error' },
       { dirName: 'invalid-config-assertation-config-type', option: 'error' },
+      { dirName: 'invalid-config-format-json', option: 'warn', format: 'json' },
     ];
 
     const validOpenapiFile = join(__dirname, 'lint-config/__fixtures__/valid-openapi.yaml');
     const invalidOpenapiFile = join(__dirname, 'lint-config/__fixtures__/invalid-openapi.yaml');
 
     test.each(lintOptions)('test with option: %s', (lintOptions) => {
-      const { dirName, option } = lintOptions;
+      const { dirName, option, format } = lintOptions;
       const folderPath = join(__dirname, `lint-config/${dirName}`);
       const relativeValidOpenapiFile = relative(folderPath, validOpenapiFile);
-      const args = [relativeValidOpenapiFile, ...(option ? [`--lint-config=${option}`] : [])];
+      const args = [
+        relativeValidOpenapiFile,
+        ...([option && `--lint-config=${option}`, format && `--format=${format}`].filter(
+          Boolean
+        ) as string[]),
+      ];
 
       const passedArgs = getParams('../../../packages/cli/src/index.ts', 'lint', args);
 
diff --git a/__tests__/lint-config/invalid-config-format-json/redocly.yaml b/__tests__/lint-config/invalid-config-format-json/redocly.yaml
new file mode 100644
index 00000000..91bc26f5
--- /dev/null
+++ b/__tests__/lint-config/invalid-config-format-json/redocly.yaml
@@ -0,0 +1,1 @@
+badKey: 1
\ No newline at end of file
diff --git a/__tests__/lint-config/invalid-config-format-json/snapshot.js b/__tests__/lint-config/invalid-config-format-json/snapshot.js
new file mode 100644
index 00000000..b251c0b4
--- /dev/null
+++ b/__tests__/lint-config/invalid-config-format-json/snapshot.js
@@ -0,0 +1,26 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`E2E lint-config test with option: {
+  dirName: 'invalid-config-format-json',
+  option: 'warn',
+  format: 'json'
+} 1`] = `
+{
+  "totals": {
+    "errors": 0,
+    "warnings": 0,
+    "ignored": 0
+  },
+  "version": "<version>",
+  "problems": []
+}
+No configurations were defined in extends -- using built in recommended configuration by default.
+Warning! This default behavior is going to be deprecated soon.
+
+validating ../__fixtures__/valid-openapi.yaml...
+../__fixtures__/valid-openapi.yaml: validated in <test>ms
+
+Woohoo! Your OpenAPI definition is valid. 🎉
+
+
+`;
diff --git a/__tests__/lint-config/invlid-lint-config-saverity/snapshot.js b/__tests__/lint-config/invlid-lint-config-saverity/snapshot.js
index 5f1109e4..8be9a6c2 100644
--- a/__tests__/lint-config/invlid-lint-config-saverity/snapshot.js
+++ b/__tests__/lint-config/invlid-lint-config-saverity/snapshot.js
@@ -13,8 +13,8 @@ Options:
   --version               Show version number.                         [boolean]
   --help                  Show help.                                   [boolean]
   --format                Use a specific output format.
-          [choices: "stylish", "codeframe", "json", "checkstyle", "codeclimate"]
-                                                          [default: "codeframe"]
+          [choices: "stylish", "codeframe", "json", "checkstyle", "codeclimate",
+                                               "summary"] [default: "codeframe"]
   --max-problems          Reduce output to max N problems.
                                                          [number] [default: 100]
   --generate-ignore-file  Generate ignore file.                        [boolean]
diff --git a/packages/core/src/__tests__/format.test.ts b/packages/core/src/__tests__/format.test.ts
new file mode 100644
index 00000000..aff17b31
--- /dev/null
+++ b/packages/core/src/__tests__/format.test.ts
@@ -0,0 +1,76 @@
+import { outdent } from 'outdent';
+
+import { formatProblems, getTotals } from '../format/format';
+
+describe('format', () => {
+  function replaceColors(log: string) {
+    return log
+      .replace(/\x1b\[33m(.*?)\x1b\[39m/g, '<o>$1</o>') // orange
+      .replace(/\x1b\[31m(.*?)\x1b\[39m/g, '<r>$1</r>'); // red
+  }
+
+  const problems = [
+    {
+      ruleId: 'spec',
+      location: [],
+      severity: 'error' as const,
+      message: 'message',
+      suggest: [],
+    },
+    {
+      ruleId: 'spec',
+      location: [],
+      severity: 'error' as const,
+      message: 'message 2',
+      suggest: [],
+    },
+    {
+      ruleId: 'other-rule',
+      location: [],
+      severity: 'warn' as const,
+      message: 'message',
+      suggest: [],
+    },
+  ];
+
+  let output = '';
+  beforeEach(() => {
+    output = '';
+    jest.spyOn(process.stderr, 'write').mockImplementation((str: string | Uint8Array) => {
+      output += str;
+      return true;
+    });
+  });
+
+  it('should correctly format summary output', () => {
+    formatProblems(problems, {
+      format: 'summary',
+      version: '1.0.0',
+      totals: getTotals(problems),
+      color: false,
+    });
+
+    expect(output).toMatchInlineSnapshot(`
+      "error   spec: 2
+      warning other-rule: 1
+
+      "
+    `);
+  });
+
+  it('should correctly format summary output in color mode', () => {
+    formatProblems(problems, {
+      format: 'summary',
+      version: '1.0.0',
+      totals: getTotals(problems),
+      color: true,
+    });
+
+    expect(replaceColors(output)).toMatchInlineSnapshot(`
+      "<r>error  </r> spec: 2
+      <o>warning</o> other-rule: 1
+
+      "
+    `);
+  });
+});