Repository: stoplightio/spectral
Pull request #569 (2019-09-18 18:05:01)

fix(stylish): support hint and print proper severity
Fixes https://github.com/stoplightio/spectral/issues/565

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No

**Screenshots**

After:
![image](https://user-images.githubusercontent.com/9273484/65173743-c2fad200-da4f-11e9-838c-ac7022e700e0.png)
![image](https://user-images.githubusercontent.com/9273484/65173770-d443de80-da4f-11e9-98e0-ff8393fd025d.png)
![image](https://user-images.githubusercontent.com/9273484/65173707-ad85a800-da4f-11e9-8914-3df14a4b543a.png)
![image](https://user-images.githubusercontent.com/9273484/65173797-e32a9100-da4f-11e9-8195-106475445325.png)





Patch:
diff --git a/src/formatters/stylish.ts b/src/formatters/stylish.ts
index 5c9b0203..cc7b355f 100644
--- a/src/formatters/stylish.ts
+++ b/src/formatters/stylish.ts
@@ -30,6 +30,8 @@ import * as table from 'text-table';
 import { DiagnosticSeverity, IRange } from '@stoplight/types';
 import { IRuleResult } from '../types';
 import { Formatter } from './types';
+import { getHighestSeverity } from './utils/getHighestSeverity';
+import { groupBySeverity } from './utils/groupBySeverity';
 import { groupBySource } from './utils/groupBySource';
 import { sortResults } from './utils/sortResults';
 
@@ -47,6 +49,38 @@ function pluralize(word: string, count: number): string {
   return count === 1 ? word : `${word}s`;
 }
 
+function formatRange(range?: IRange): string {
+  if (!range) return '';
+
+  return ` ${range.start.line + 1}:${range.start.character + 1}`;
+}
+
+const SEVERITY_COLORS = {
+  [DiagnosticSeverity.Error]: 'red',
+  [DiagnosticSeverity.Warning]: 'yellow',
+  [DiagnosticSeverity.Information]: 'blue',
+  [DiagnosticSeverity.Hint]: 'white',
+};
+
+function getColorForSeverity(severity: DiagnosticSeverity) {
+  return SEVERITY_COLORS[severity];
+}
+
+function getMessageType(severity: DiagnosticSeverity) {
+  const color = getColorForSeverity(severity);
+
+  switch (severity) {
+    case DiagnosticSeverity.Error:
+      return chalk[color]('error');
+    case DiagnosticSeverity.Warning:
+      return chalk[color]('warning');
+    case DiagnosticSeverity.Information:
+      return chalk[color]('information');
+    default:
+      return chalk[color]('hint');
+  }
+}
+
 // -----------------------------------------------------------------------------
 // Public Interface
 // -----------------------------------------------------------------------------
@@ -56,41 +90,33 @@ export const stylish: Formatter = results => {
   let errorCount = 0;
   let warningCount = 0;
   let infoCount = 0;
-  let summaryColor = 'white';
+  let hintCount = 0;
+  const summaryColor = getColorForSeverity(getHighestSeverity(results));
 
   const groupedResults = groupBySource(results);
   Object.keys(groupedResults).map((path, index) => {
     const pathResults = groupedResults[path];
 
-    const errors = pathResults.filter((result: IRuleResult) => result.severity === DiagnosticSeverity.Error);
-    const warnings = pathResults.filter((result: IRuleResult) => result.severity === DiagnosticSeverity.Warning);
-    const infos = pathResults.filter((result: IRuleResult) => result.severity === DiagnosticSeverity.Information);
+    const {
+      [DiagnosticSeverity.Error]: errors,
+      [DiagnosticSeverity.Warning]: warnings,
+      [DiagnosticSeverity.Information]: infos,
+      [DiagnosticSeverity.Hint]: hints,
+    } = groupBySeverity(pathResults);
 
     errorCount += errors.length;
     warningCount += warnings.length;
     infoCount += infos.length;
+    hintCount += hints.length;
 
     output += `${chalk.underline(path)}\n`;
 
-    const pathTableData = sortResults(pathResults).map((result: IRuleResult) => {
-      let messageType;
-
-      if (result.severity === DiagnosticSeverity.Error) {
-        messageType = chalk.red('error');
-        summaryColor = 'red';
-      } else if (result.severity === DiagnosticSeverity.Warning) {
-        messageType = chalk.yellow('warning');
-
-        // we should always display red color for summary if there are any errors
-        if (summaryColor !== 'red') {
-          summaryColor = 'yellow';
-        }
-      } else {
-        messageType = chalk.yellow('white');
-      }
-
-      return [formatRange(result.range), messageType, result.code !== undefined ? result.code : '', result.message];
-    });
+    const pathTableData = sortResults(pathResults).map((result: IRuleResult) => [
+      formatRange(result.range),
+      getMessageType(result.severity),
+      result.code !== undefined ? result.code : '',
+      result.message,
+    ]);
 
     output += `${table(pathTableData, {
       align: ['c', 'r', 'l'],
@@ -105,7 +131,7 @@ export const stylish: Formatter = results => {
       .join('\n')}\n\n`;
   });
 
-  const total = errorCount + warningCount + infoCount;
+  const total = errorCount + warningCount + infoCount + hintCount;
 
   if (total > 0) {
     output += chalk[summaryColor].bold(
@@ -122,6 +148,9 @@ export const stylish: Formatter = results => {
         ', ',
         infoCount,
         pluralize(' info', infoCount),
+        ', ',
+        hintCount,
+        pluralize(' hint', hintCount),
         ')\n',
       ].join(''),
     );
@@ -129,9 +158,3 @@ export const stylish: Formatter = results => {
 
   return total > 0 ? output : '';
 };
-
-const formatRange = (range?: IRange): string => {
-  if (!range) return '';
-
-  return ` ${range.start.line + 1}:${range.start.character + 1}`;
-};
diff --git a/src/formatters/utils/getHighestSeverity.ts b/src/formatters/utils/getHighestSeverity.ts
new file mode 100644
index 00000000..46672dc8
--- /dev/null
+++ b/src/formatters/utils/getHighestSeverity.ts
@@ -0,0 +1,5 @@
+import { DiagnosticSeverity } from '@stoplight/types';
+import { IRuleResult } from '../../types';
+
+export const getHighestSeverity = (results: IRuleResult[]) =>
+  results.length === 0 ? DiagnosticSeverity.Hint : Math.min(...results.map(({ severity }) => severity));
diff --git a/src/formatters/utils/groupBySeverity.ts b/src/formatters/utils/groupBySeverity.ts
new file mode 100644
index 00000000..f1d5f85b
--- /dev/null
+++ b/src/formatters/utils/groupBySeverity.ts
@@ -0,0 +1,16 @@
+import { DiagnosticSeverity, Dictionary } from '@stoplight/types';
+import { IRuleResult } from '../../types';
+
+export const groupBySeverity = (results: IRuleResult[]) =>
+  results.reduce<Dictionary<IRuleResult[], DiagnosticSeverity>>(
+    (group, result: IRuleResult) => {
+      group[result.severity].push(result);
+      return group;
+    },
+    {
+      [DiagnosticSeverity.Error]: [],
+      [DiagnosticSeverity.Warning]: [],
+      [DiagnosticSeverity.Hint]: [],
+      [DiagnosticSeverity.Information]: [],
+    },
+  );


Test patch:
diff --git a/src/formatters/__tests__/__fixtures__/mixed-errors.json b/src/formatters/__tests__/__fixtures__/mixed-errors.json
new file mode 100644
index 00000000..f9b9b31a
--- /dev/null
+++ b/src/formatters/__tests__/__fixtures__/mixed-errors.json
@@ -0,0 +1,123 @@
+[
+  {
+    "code": "info-contact",
+    "path": [
+      "info"
+    ],
+    "message": "Info object should contain `contact` object.",
+    "severity": 3,
+    "range": {
+      "start": {
+        "line": 2,
+        "character": 9
+      },
+      "end": {
+        "line": 6,
+        "character": 19
+      }
+    },
+    "source": "/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json"
+  },
+  {
+    "code": "info-description",
+    "path": [
+      "info"
+    ],
+    "message": "OpenAPI object info `description` must be present and non-empty string.",
+    "severity": 1,
+    "range": {
+      "start": {
+        "line": 2,
+        "character": 9
+      },
+      "end": {
+        "line": 6,
+        "character": 19
+      }
+    },
+    "source": "/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json"
+  },
+  {
+    "code": "operation-description",
+    "path": [
+      "paths",
+      "/pets",
+      "get"
+    ],
+    "message": "Operation `description` must be present and non-empty string.",
+    "severity": 2,
+    "range": {
+      "start": {
+        "line": 16,
+        "character": 12
+      },
+      "end": {
+        "line": 56,
+        "character": 54
+      }
+    },
+    "source": "/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json"
+  },
+  {
+    "code": "operation-description",
+    "path": [
+      "paths",
+      "/pets",
+      "post"
+    ],
+    "message": "Operation `description` must be present and non-empty string.",
+    "severity": 2,
+    "range": {
+      "start": {
+        "line": 63,
+        "character": 13
+      },
+      "end": {
+        "line": 76,
+        "character": 54
+      }
+    },
+    "source": "/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json"
+  },
+  {
+    "code": "operation-description",
+    "path": [
+      "paths",
+      "/pets/{petId}",
+      "get"
+    ],
+    "message": "Operation `description` must be present and non-empty string.",
+    "severity": 2,
+    "range": {
+      "start": {
+        "line": 85,
+        "character": 12
+      },
+      "end": {
+        "line": 116,
+        "character": 54
+      }
+    },
+    "source": "/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json"
+  },
+  {
+    "code": "info-matches-stoplight",
+    "path": [
+      "info",
+      "title"
+    ],
+    "message": "Info must contain Stoplight",
+    "severity": 0,
+    "range": {
+      "start": {
+        "line": 4,
+        "character": 13
+      },
+      "end": {
+        "line": 4,
+        "character": 31
+      }
+    },
+    "source": "/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json"
+  }
+]
diff --git a/src/formatters/__tests__/__fixtures__/oas3-schema-errors.json b/src/formatters/__tests__/__fixtures__/oas3-schema-errors.json
index d4ce7719..c43eaf08 100644
--- a/src/formatters/__tests__/__fixtures__/oas3-schema-errors.json
+++ b/src/formatters/__tests__/__fixtures__/oas3-schema-errors.json
@@ -1,7 +1,6 @@
 [
   {
     "code": "oas3-schema",
-    "summary": "Validate structure of OpenAPIv3 specification.",
     "message": "should NOT have additional properties: type",
     "path": [
       "paths",
@@ -27,7 +26,6 @@
   },
   {
     "code": "oas3-schema",
-    "summary": "Validate structure of OpenAPIv3 specification.",
     "message": "should match exactly one schema in oneOf",
     "path": [
       "paths",
@@ -53,7 +51,6 @@
   },
   {
     "code": "oas3-schema",
-    "summary": "Validate structure of OpenAPIv3 specification.",
     "message": "should have required property '$ref'",
     "path": [
       "paths",
diff --git a/src/formatters/__tests__/stylish.test.ts b/src/formatters/__tests__/stylish.test.ts
index aa5d6781..fcf507d1 100644
--- a/src/formatters/__tests__/stylish.test.ts
+++ b/src/formatters/__tests__/stylish.test.ts
@@ -1,12 +1,33 @@
+import chalk from 'chalk';
 import { stylish } from '../stylish';
 
-const results = require('./__fixtures__/oas3-schema-errors.json');
+const oas3SchemaErrors = require('./__fixtures__/oas3-schema-errors.json');
+const mixedErrors = require('./__fixtures__/mixed-errors.json');
 
 describe('Stylish formatter', () => {
   test('should prefer message for oas-schema errors', () => {
-    const result = stylish(results);
+    const result = stylish(oas3SchemaErrors);
     expect(result).toContain('oas3-schema  should NOT have additional properties: type\n');
     expect(result).toContain('oas3-schema  should match exactly one schema in oneOf');
     expect(result).toContain("oas3-schema  should have required property '$ref'\n");
   });
+
+  test('should display proper severity level', () => {
+    const result = stylish(mixedErrors);
+    expect(result).toContain(`
+  3:10         ${chalk.white('hint')}  info-contact            Info object should contain \`contact\` object.
+  3:10      ${chalk.yellow(
+    'warning',
+  )}  info-description        OpenAPI object info \`description\` must be present and non-empty string.
+  5:14        ${chalk.red('error')}  info-matches-stoplight  Info must contain Stoplight
+ 17:13  ${chalk.blue(
+   'information',
+ )}  operation-description   Operation \`description\` must be present and non-empty string.
+ 64:14  ${chalk.blue(
+   'information',
+ )}  operation-description   Operation \`description\` must be present and non-empty string.
+ 86:13  ${chalk.blue(
+   'information',
+ )}  operation-description   Operation \`description\` must be present and non-empty string.`);
+  });
 });
diff --git a/test-harness/scenarios/parameter-description-parameters.oas2.scenario b/test-harness/scenarios/parameter-description-parameters.oas2.scenario
index 5dbae55c..048fbfc3 100644
--- a/test-harness/scenarios/parameter-description-parameters.oas2.scenario
+++ b/test-harness/scenarios/parameter-description-parameters.oas2.scenario
@@ -44,4 +44,4 @@ OpenAPI 2.0 (Swagger) detected
  14:11  warning  oas2-parameter-description  Parameter objects should have a `description`.
  25:13  warning  oas2-parameter-description  Parameter objects should have a `description`.
 
-✖ 3 problems (0 errors, 3 warnings, 0 infos)
\ No newline at end of file
+✖ 3 problems (0 errors, 3 warnings, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/parameter-description-parameters.oas3.scenario b/test-harness/scenarios/parameter-description-parameters.oas3.scenario
index dbb212bf..b387376e 100644
--- a/test-harness/scenarios/parameter-description-parameters.oas3.scenario
+++ b/test-harness/scenarios/parameter-description-parameters.oas3.scenario
@@ -31,4 +31,4 @@ OpenAPI 3.x detected
   9:11  warning  oas3-parameter-description  Parameter objects should have a `description`.
  16:15  warning  oas3-parameter-description  Parameter objects should have a `description`.
 
-✖ 3 problems (0 errors, 3 warnings, 0 infos)
\ No newline at end of file
+✖ 3 problems (0 errors, 3 warnings, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/results-default-output.oas3.scenario b/test-harness/scenarios/results-default-output.oas3.scenario
index 59916507..29b34533 100644
--- a/test-harness/scenarios/results-default-output.oas3.scenario
+++ b/test-harness/scenarios/results-default-output.oas3.scenario
@@ -15,4 +15,4 @@ lint {document} --output=./test-harness/tmp/results-default-output.txt > /dev/nu
  2:6  warning  info-contact      Info object should contain `contact` object.
  2:6  warning  info-description  OpenAPI object info `description` must be present and non-empty string.
 
-✖ 3 problems (0 errors, 3 warnings, 0 infos)
\ No newline at end of file
+✖ 3 problems (0 errors, 3 warnings, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/results-default.oas3.scenario b/test-harness/scenarios/results-default.oas3.scenario
index 370c7393..e77c7d12 100644
--- a/test-harness/scenarios/results-default.oas3.scenario
+++ b/test-harness/scenarios/results-default.oas3.scenario
@@ -16,4 +16,4 @@ OpenAPI 3.x detected
  2:6  warning  info-contact      Info object should contain `contact` object.
  2:6  warning  info-description  OpenAPI object info `description` must be present and non-empty string.
 
-✖ 3 problems (0 errors, 3 warnings, 0 infos)
\ No newline at end of file
+✖ 3 problems (0 errors, 3 warnings, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/results-format-stylish.oas3.scenario b/test-harness/scenarios/results-format-stylish.oas3.scenario
index 2c6cf814..480367a7 100644
--- a/test-harness/scenarios/results-format-stylish.oas3.scenario
+++ b/test-harness/scenarios/results-format-stylish.oas3.scenario
@@ -16,4 +16,4 @@ OpenAPI 3.x detected
  2:6  warning  info-contact      Info object should contain `contact` object.
  2:6  warning  info-description  OpenAPI object info `description` must be present and non-empty string.
 
-✖ 3 problems (0 errors, 3 warnings, 0 infos)
\ No newline at end of file
+✖ 3 problems (0 errors, 3 warnings, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/results-skip-rule.oas3.scenario b/test-harness/scenarios/results-skip-rule.oas3.scenario
index 55bd5b35..8b60c124 100644
--- a/test-harness/scenarios/results-skip-rule.oas3.scenario
+++ b/test-harness/scenarios/results-skip-rule.oas3.scenario
@@ -15,4 +15,4 @@ OpenAPI 3.x detected
  1:1  warning  api-servers       OpenAPI `servers` must be present and non-empty array.
  2:6  warning  info-description  OpenAPI object info `description` must be present and non-empty string.
 
-✖ 2 problems (0 errors, 2 warnings, 0 infos)
\ No newline at end of file
+✖ 2 problems (0 errors, 2 warnings, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/results-skip-rules-multiple.oas3.scenario b/test-harness/scenarios/results-skip-rules-multiple.oas3.scenario
index 4ac8e7a9..2024a99f 100644
--- a/test-harness/scenarios/results-skip-rules-multiple.oas3.scenario
+++ b/test-harness/scenarios/results-skip-rules-multiple.oas3.scenario
@@ -14,4 +14,4 @@ OpenAPI 3.x detected
 {document}
  1:1  warning  api-servers  OpenAPI `servers` must be present and non-empty array.
 
-✖ 1 problem (0 errors, 1 warning, 0 infos)
\ No newline at end of file
+✖ 1 problem (0 errors, 1 warning, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/rulesets/oas-mixed-severity.json b/test-harness/scenarios/rulesets/oas-mixed-severity.json
new file mode 100644
index 00000000..244a278b
--- /dev/null
+++ b/test-harness/scenarios/rulesets/oas-mixed-severity.json
@@ -0,0 +1,21 @@
+{
+  "extends": ["spectral:oas"],
+  "rules": {
+    "info-contact": "error",
+    "operation-description": "info",
+    "info-matches-stoplight": {
+      "message": "Info must contain Stoplight",
+      "given": "$.info",
+      "severity": "hint",
+      "type": "style",
+      "recommended": true,
+      "then": {
+        "field": "title",
+        "function": "pattern",
+        "functionOptions": {
+          "match": "Stoplight"
+        }
+      }
+    }
+  }
+}
diff --git a/test-harness/scenarios/severity/display-warnings.oas3.scenario b/test-harness/scenarios/severity/display-warnings.oas3.scenario
index 49216f85..d752660a 100644
--- a/test-harness/scenarios/severity/display-warnings.oas3.scenario
+++ b/test-harness/scenarios/severity/display-warnings.oas3.scenario
@@ -52,4 +52,4 @@ OpenAPI 3.x detected
  2:6  warning  info-description       OpenAPI object info `description` must be present and non-empty string.
  9:9  warning  operation-description  Operation `description` must be present and non-empty string.
 
-✖ 4 problems (0 errors, 4 warnings, 0 infos)
\ No newline at end of file
+✖ 4 problems (0 errors, 4 warnings, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/severity/fail-on-error-no-error.scenario b/test-harness/scenarios/severity/fail-on-error-no-error.scenario
index 0125748d..23dc7e78 100644
--- a/test-harness/scenarios/severity/fail-on-error-no-error.scenario
+++ b/test-harness/scenarios/severity/fail-on-error-no-error.scenario
@@ -33,4 +33,4 @@ OpenAPI 3.x detected
  12:10  warning  operation-description  Operation `description` must be present and non-empty string.
  12:10  warning  operation-tags         Operation should have non-empty `tags` array.
 
-✖ 7 problems (0 errors, 7 warnings, 0 infos)
\ No newline at end of file
+✖ 7 problems (0 errors, 7 warnings, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/severity/fail-on-error.oas3.scenario b/test-harness/scenarios/severity/fail-on-error.oas3.scenario
index 01b0f164..23e99563 100644
--- a/test-harness/scenarios/severity/fail-on-error.oas3.scenario
+++ b/test-harness/scenarios/severity/fail-on-error.oas3.scenario
@@ -35,4 +35,4 @@ OpenAPI 3.x detected
  12:10  warning  operation-tags                Operation should have non-empty `tags` array.
  13:20    error  operation-operationId-unique  Every operation must have a unique `operationId`.
 
-✖ 9 problems (2 errors, 7 warnings, 0 infos)
\ No newline at end of file
+✖ 9 problems (2 errors, 7 warnings, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/severity/stylish-display-proper-names.scenario b/test-harness/scenarios/severity/stylish-display-proper-names.scenario
new file mode 100644
index 00000000..1f0efe02
--- /dev/null
+++ b/test-harness/scenarios/severity/stylish-display-proper-names.scenario
@@ -0,0 +1,101 @@
+====test====
+The name of severity levels are display correctly by stylish formatter
+====document====
+{
+  "openapi": "3.0.0",
+  "info": {
+    "version": "1.0.0",
+    "title": "Swagger Petstore",
+    "license": {
+      "name": "MIT"
+    }
+  },
+  "paths": {
+    "/pets": {
+      "get": {
+        "summary": "List all pets",
+        "operationId": "listPets",
+        "tags": ["pets"],
+        "parameters": [
+          {
+            "name": "limit",
+            "in": "query",
+            "description": "How many items to return at one time (max 100)",
+            "required": false,
+            "schema": {
+              "type": "integer",
+              "format": "int32"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "A paged array of pets",
+            "headers": {
+              "x-next": {
+                "description": "A link to the next page of responses",
+                "schema": {
+                  "type": "string"
+                }
+              }
+            },
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Pets"
+                }
+              }
+            }
+          },
+          "default": {
+            "description": "unexpected error",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                }
+              }
+            }
+          }
+        }
+      },
+      "post": {
+        "summary": "Create a pet",
+        "operationId": "createPets",
+        "tags": ["pets"],
+        "responses": {
+          "201": {
+            "description": "Null response"
+          },
+          "default": {
+            "description": "unexpected error",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
+
+====command====
+lint --ruleset ./test-harness/scenarios/rulesets/oas-mixed-severity.json {document}
+====stdout====
+OpenAPI 3.x detected
+
+{document}
+  1:1       warning  api-servers             OpenAPI `servers` must be present and non-empty array.
+  3:10        error  info-contact            Info object should contain `contact` object.
+  3:10      warning  info-description        OpenAPI object info `description` must be present and non-empty string.
+  5:14         hint  info-matches-stoplight  Info must contain Stoplight
+ 12:13  information  operation-description   Operation `description` must be present and non-empty string.
+ 42:27        error  invalid-ref             '#/components/schemas/Pets' does not exist
+ 52:27        error  invalid-ref             '#/components/schemas/Error' does not exist
+ 59:14  information  operation-description   Operation `description` must be present and non-empty string.
+
+✖ 8 problems (3 errors, 2 warnings, 2 infos, 1 hint)