Repository: stoplightio/spectral
Pull request #1361 (2020-09-30 15:50:20)

feat(ruleset): introduce parser options
Fixes https://github.com/stoplightio/spectral/issues/1350

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No (I believe so? I know we change the default value, but one can easily tune it down if needed)





Patch:
diff --git a/docs/guides/4-custom-rulesets.md b/docs/guides/4-custom-rulesets.md
index 262381a1..265499c7 100644
--- a/docs/guides/4-custom-rulesets.md
+++ b/docs/guides/4-custom-rulesets.md
@@ -166,6 +166,19 @@ message: "{{value}} is greater than 0"
 message: "{{path}} cannot point at remote reference"
 ```
 
+## Parsing Options
+
+If you do not care about duplicate keys or invalid values (such as non-string mapping keys in YAML), you can tune their severity using `parserOptions` setting.
+
+```yaml
+extends: spectral:oas
+parserOptions:
+  duplicateKeys: warn # error is the default value
+  incompatibleValues: off # error is the default value
+```
+
+`parserOptions` is not inherited by extended rulesets.
+
 ## Documentation URL
 
 Optionally provide a documentation URL to your ruleset in order to help end-users find more information about various warnings. Result messages will sometimes be more than enough to explain what the problem is, but it can also be beneficial to explain _why_ a message exists, and this is a great place to do that.
diff --git a/src/consts.ts b/src/consts.ts
index 0aafcf77..3e9cb697 100644
--- a/src/consts.ts
+++ b/src/consts.ts
@@ -1,3 +1,11 @@
+import { IParserOptions } from './types/ruleset';
+import { DiagnosticSeverity } from '@stoplight/types';
+
 export const NPM_PKG_ROOT = 'https://unpkg.com/';
 export const SPECTRAL_PKG_NAME = '@stoplight/spectral';
 export const SPECTRAL_PKG_VERSION = '';
+
+export const DEFAULT_PARSER_OPTIONS = Object.freeze<Required<IParserOptions>>({
+  incompatibleValues: DiagnosticSeverity.Error,
+  duplicateKeys: DiagnosticSeverity.Error,
+});
diff --git a/src/document.ts b/src/document.ts
index e1d9cba5..9482a842 100644
--- a/src/document.ts
+++ b/src/document.ts
@@ -28,7 +28,7 @@ export function normalizeSource(source: Optional<string>): string | null {
 export class Document<D = unknown, R extends IParserResult = IParserResult<D>> implements IDocument<D> {
   protected readonly parserResult: R;
   public readonly source: string | null;
-  public readonly diagnostics: ReadonlyArray<IRuleResult>;
+  public readonly diagnostics: IRuleResult[];
   public formats?: string[] | null;
 
   constructor(protected readonly input: string, protected readonly parser: IParser<R>, source?: string) {
@@ -66,7 +66,7 @@ export class Document<D = unknown, R extends IParserResult = IParserResult<D>> i
 
 export class ParsedDocument<D = unknown, R extends IParsedResult = IParsedResult> implements IDocument<D> {
   public readonly source: string | null;
-  public readonly diagnostics: ReadonlyArray<IRuleResult>;
+  public readonly diagnostics: IRuleResult[];
   public formats?: string[] | null;
 
   constructor(protected readonly parserResult: R) {
diff --git a/src/errorMessages.ts b/src/errorMessages.ts
index a6e466aa..f133a8a2 100644
--- a/src/errorMessages.ts
+++ b/src/errorMessages.ts
@@ -37,7 +37,7 @@ export function formatParserDiagnostics(diagnostics: ReadonlyArray<IDiagnostic>,
     code: 'parser',
     message: getDiagnosticErrorMessage(diagnostic),
     path: diagnostic.path ?? [],
-    ...(source !== null && { source }),
+    ...(source !== null ? { source } : null),
   }));
 }
 
diff --git a/src/meta/rule.schema.json b/src/meta/rule.schema.json
index 01091f59..7ffb1dda 100644
--- a/src/meta/rule.schema.json
+++ b/src/meta/rule.schema.json
@@ -323,23 +323,8 @@
       ],
       "type": "object"
     },
-    "DiagnosticSeverity": {
-      "enum": [-1, 0, 1, 2, 3],
-      "type": "number"
-    },
-    "HumanReadableSeverity": {
-      "type": "string",
-      "enum": ["error", "warn", "info", "hint", "off"]
-    },
     "Severity": {
-      "oneOf": [
-        {
-          "$ref": "#/definitions/DiagnosticSeverity"
-        },
-        {
-          "$ref": "#/definitions/HumanReadableSeverity"
-        }
-      ]
+      "$ref": "shared.json#/Severity"
     }
   },
   "oneOf": [
@@ -412,7 +397,7 @@
       "type": "object"
     },
     {
-      "$ref": "#/definitions/HumanReadableSeverity"
+      "$ref": "shared.json#/HumanReadableSeverity"
     },
     {
       "type": "boolean"
diff --git a/src/meta/ruleset.schema.json b/src/meta/ruleset.schema.json
index 3409482f..56c6490f 100644
--- a/src/meta/ruleset.schema.json
+++ b/src/meta/ruleset.schema.json
@@ -77,6 +77,18 @@
           "type": "string"
         }
       }
+    },
+    "parserOptions": {
+      "type": "object",
+      "properties": {
+        "duplicateKeys": {
+          "$ref": "shared.json#/Severity"
+        },
+        "incompatibleValues": {
+          "$ref": "shared.json#/Severity"
+        }
+      },
+      "additionalProperties": false
     }
   },
   "anyOf": [
diff --git a/src/meta/shared.json b/src/meta/shared.json
new file mode 100644
index 00000000..45ae93ff
--- /dev/null
+++ b/src/meta/shared.json
@@ -0,0 +1,19 @@
+{
+  "$id": "http://stoplight.io/schemas/shared.json",
+  "DiagnosticSeverity": {
+    "enum": [-1, 0, 1, 2, 3]
+  },
+  "HumanReadableSeverity": {
+    "enum": ["error", "warn", "info", "hint", "off"]
+  },
+  "Severity": {
+    "oneOf": [
+      {
+        "$ref": "#/DiagnosticSeverity"
+      },
+      {
+        "$ref": "#/HumanReadableSeverity"
+      }
+    ]
+  }
+}
diff --git a/src/rulesets/reader.ts b/src/rulesets/reader.ts
index 7b766995..b74b9a58 100644
--- a/src/rulesets/reader.ts
+++ b/src/rulesets/reader.ts
@@ -40,6 +40,10 @@ export async function readRuleset(uris: string | string[], opts?: IRulesetReadOp
     Object.assign(base.rules, resolvedRuleset.rules);
     Object.assign(base.functions, resolvedRuleset.functions);
     Object.assign(base.exceptions, resolvedRuleset.exceptions);
+
+    if (resolvedRuleset.parserOptions !== void 0 && !('parserOptions' in base)) {
+      base.parserOptions = resolvedRuleset.parserOptions;
+    }
   }
 
   return base;
@@ -89,6 +93,7 @@ const createRulesetProcessor = (
       rules,
       functions,
       exceptions,
+      ...('parserOptions' in ruleset ? { parserOptions: ruleset.parserOptions } : null),
     };
 
     const extendedRulesets = ruleset.extends;
diff --git a/src/rulesets/validation.ts b/src/rulesets/validation.ts
index 9c7c37ff..3ab86dc1 100644
--- a/src/rulesets/validation.ts
+++ b/src/rulesets/validation.ts
@@ -4,10 +4,11 @@ import { isObject } from 'lodash';
 import { FileRule, IRulesetFile } from '../types/ruleset';
 import * as ruleSchema from '../meta/rule.schema.json';
 import * as rulesetSchema from '../meta/ruleset.schema.json';
+import * as shared from '../meta/shared.json';
 import { IFunction, IFunctionPaths, IFunctionValues, IRule, JSONSchema } from '../types';
 
 const ajv = new AJV({ allErrors: true, jsonPointers: true });
-const validate = ajv.addSchema(ruleSchema).compile(rulesetSchema);
+const validate = ajv.addSchema(ruleSchema).addSchema(shared).compile(rulesetSchema);
 
 const serializeAJVErrors = (errors: AJV.ErrorObject[]): string =>
   errors.map(({ message, dataPath }) => `${dataPath} ${message}`).join('\n');
diff --git a/src/spectral.ts b/src/spectral.ts
index 0674c938..0b21462c 100644
--- a/src/spectral.ts
+++ b/src/spectral.ts
@@ -32,9 +32,11 @@ import {
   RuleCollection,
   RunRuleCollection,
 } from './types';
-import { IRuleset, RulesetExceptionCollection } from './types/ruleset';
+import { IParserOptions, IRuleset, RulesetExceptionCollection } from './types/ruleset';
 import { ComputeFingerprintFunc, defaultComputeResultFingerprint, empty, isNimmaEnvVariableSet } from './utils';
 import { generateDocumentWideResult } from './utils/generateDocumentWideResult';
+import { getDiagnosticSeverity } from './rulesets/severity';
+import { DEFAULT_PARSER_OPTIONS } from './consts';
 
 memoize.Cache = WeakMap;
 
@@ -48,6 +50,7 @@ export class Spectral {
   public readonly rules: RunRuleCollection = {};
   public readonly exceptions: RulesetExceptionCollection = {};
   public readonly formats: RegisteredFormats;
+  public readonly parserOptions: Required<IParserOptions> = { ...DEFAULT_PARSER_OPTIONS };
 
   protected readonly runtime: RunnerRuntime;
 
@@ -56,12 +59,12 @@ export class Spectral {
   constructor(protected readonly opts?: IConstructorOpts) {
     this._computeFingerprint = memoize(opts?.computeFingerprint ?? defaultComputeResultFingerprint);
 
-    if (opts?.proxyUri) {
+    if (opts?.proxyUri !== void 0) {
       // using eval so bundlers do not include proxy-agent when Spectral is used in the browser
       const ProxyAgent = eval('require')('proxy-agent');
       this.agent = new ProxyAgent(opts.proxyUri);
     }
-    if (opts?.resolver) {
+    if (opts?.resolver !== void 0) {
       this._resolver = opts.resolver;
     } else {
       this._resolver =
@@ -83,15 +86,35 @@ export class Spectral {
     target: IParsedResult | IDocument | object | string,
     documentUri: Optional<string>,
   ): IDocument {
-    return target instanceof Document
-      ? target
-      : isParsedResult(target)
-      ? new ParsedDocument(target)
-      : new Document<unknown, YamlParserResult<unknown>>(
-          typeof target === 'string' ? target : stringify(target, void 0, 2),
-          Parsers.Yaml,
-          documentUri,
-        );
+    const document =
+      target instanceof Document
+        ? target
+        : isParsedResult(target)
+        ? new ParsedDocument(target)
+        : new Document<unknown, YamlParserResult<unknown>>(
+            typeof target === 'string' ? target : stringify(target, void 0, 2),
+            Parsers.Yaml,
+            documentUri,
+          );
+
+    let i = -1;
+    for (const diagnostic of document.diagnostics.slice()) {
+      i++;
+      if (diagnostic.code !== 'parser') continue;
+
+      if (diagnostic.message.startsWith('Mapping key must be a string scalar rather than')) {
+        diagnostic.severity = getDiagnosticSeverity(this.parserOptions.incompatibleValues);
+      } else if (diagnostic.message.startsWith('Duplicate key')) {
+        diagnostic.severity = getDiagnosticSeverity(this.parserOptions.duplicateKeys);
+      }
+
+      if (diagnostic.severity === -1) {
+        document.diagnostics.splice(i, 1);
+        i--;
+      }
+    }
+
+    return document;
   }
 
   public async runWithResolved(
@@ -229,6 +252,10 @@ export class Spectral {
     );
 
     this.setExceptions(ruleset.exceptions);
+
+    if (ruleset.parserOptions !== void 0) {
+      Object.assign<Required<IParserOptions>, IParserOptions>(this.parserOptions, ruleset.parserOptions);
+    }
   }
 
   public registerFormat(format: string, fn: FormatLookup): void {
diff --git a/src/types/ruleset.ts b/src/types/ruleset.ts
index ac1d4d0c..25d4c771 100644
--- a/src/types/ruleset.ts
+++ b/src/types/ruleset.ts
@@ -21,10 +21,16 @@ export interface IRulesetFunctionDefinition {
 export type RulesetFunctionCollection = Dictionary<IRulesetFunctionDefinition, string>;
 export type RulesetExceptionCollection = Dictionary<string[], string>;
 
+export interface IParserOptions {
+  duplicateKeys?: DiagnosticSeverity | HumanReadableDiagnosticSeverity;
+  incompatibleValues?: DiagnosticSeverity | HumanReadableDiagnosticSeverity;
+}
+
 export interface IRuleset {
   rules: RuleCollection;
   functions: RulesetFunctionCollection;
   exceptions: RulesetExceptionCollection;
+  parserOptions?: IParserOptions;
 }
 
 export interface IRulesetFile {
@@ -35,4 +41,5 @@ export interface IRulesetFile {
   functionsDir?: string;
   functions?: Array<string | [string, JSONSchema]>;
   except?: RulesetExceptionCollection;
+  parserOptions?: IParserOptions;
 }


Test patch:
diff --git a/src/__tests__/linter.test.ts b/src/__tests__/linter.test.ts
index b19c1905..ac5230f0 100644
--- a/src/__tests__/linter.test.ts
+++ b/src/__tests__/linter.test.ts
@@ -8,6 +8,7 @@ import { mergeRules, readRuleset } from '../rulesets';
 import { RuleCollection, Spectral } from '../spectral';
 import { httpAndFileResolver } from '../resolvers/http-and-file';
 import { Parsers, Document } from '..';
+import { IParser } from '../parsers/types';
 
 const invalidSchema = JSON.stringify(require('./__fixtures__/petstore.invalid-schema.oas3.json'));
 const studioFixture = JSON.stringify(require('./__fixtures__/studio-default-fixture-oas3.json'), null, 2);
@@ -856,11 +857,145 @@ responses:: !!foo
             line: 1,
           },
         },
-        severity: DiagnosticSeverity.Warning,
+        severity: DiagnosticSeverity.Error,
       },
     ]);
   });
 
+  describe('parser options', () => {
+    test('should allow changing the severity of invalid YAML mapping keys diagnostics', async () => {
+      spectral.setRuleset({
+        rules: {},
+        functions: {},
+        exceptions: {},
+        parserOptions: {
+          incompatibleValues: 'info',
+        },
+      });
+      const results = await spectral.run(
+        `responses:
+  200:
+    description: ''
+  '400':
+    description: ''`,
+        { ignoreUnknownFormat: true },
+      );
+
+      expect(results).toEqual([
+        {
+          code: 'parser',
+          message: 'Mapping key must be a string scalar rather than number',
+          path: ['responses', '200'],
+          range: {
+            end: {
+              character: 5,
+              line: 1,
+            },
+            start: {
+              character: 2,
+              line: 1,
+            },
+          },
+          severity: DiagnosticSeverity.Information,
+        },
+      ]);
+    });
+
+    test('should allow disabling invalid YAML mapping keys diagnostics', async () => {
+      spectral.setRuleset({
+        rules: {},
+        functions: {},
+        exceptions: {},
+        parserOptions: {
+          incompatibleValues: 'off',
+        },
+      });
+      const results = await spectral.run(
+        `responses:
+  200:
+    description: ''
+  500:
+  '400':
+    description: ''`,
+        { ignoreUnknownFormat: true },
+      );
+
+      expect(results).toEqual([]);
+    });
+
+    test.each<keyof typeof Parsers>(['Json', 'Yaml'])(
+      'should allow changing the severity of duplicate key diagnostics reported by %s parser',
+      async parser => {
+        spectral.setRuleset({
+          rules: {},
+          functions: {},
+          exceptions: {},
+          parserOptions: {
+            duplicateKeys: 'info',
+          },
+        });
+
+        const results = await spectral.run(
+          new Document(
+            `{
+  "200": {},
+  "200": {}
+}`,
+            Parsers[parser] as IParser,
+          ),
+          { ignoreUnknownFormat: true },
+        );
+
+        expect(results).toEqual([
+          {
+            code: 'parser',
+            message: 'Duplicate key: 200',
+            path: ['200'],
+            range: {
+              end: {
+                character: 7,
+                line: 2,
+              },
+              start: {
+                character: 2,
+                line: 2,
+              },
+            },
+            severity: DiagnosticSeverity.Information,
+          },
+        ]);
+      },
+    );
+
+    test.each<keyof typeof Parsers>(['Json', 'Yaml'])(
+      'should allow disabling duplicate key diagnostics reported by %s parser',
+      async parser => {
+        spectral.setRuleset({
+          rules: {},
+          functions: {},
+          exceptions: {},
+          parserOptions: {
+            duplicateKeys: 'off',
+          },
+        });
+
+        const results = await spectral.run(
+          new Document(
+            `{
+  "200": {},
+  "200": {},
+  "200": {}
+}`,
+            Parsers[parser] as IParser,
+          ),
+          { ignoreUnknownFormat: true },
+        );
+
+        expect(results).toEqual([]);
+      },
+    );
+  });
+
   describe('functional tests for the given property', () => {
     let fakeLintingFunction: any;
 
diff --git a/src/rulesets/__tests__/__fixtures__/parser-options-ruleset.json b/src/rulesets/__tests__/__fixtures__/parser-options-ruleset.json
new file mode 100644
index 00000000..fd57dac3
--- /dev/null
+++ b/src/rulesets/__tests__/__fixtures__/parser-options-ruleset.json
@@ -0,0 +1,7 @@
+{
+  "extends": [],
+  "parserOptions": {
+    "incompatibleValues": "off",
+    "duplicateKeys": "warn"
+  }
+}
diff --git a/src/rulesets/__tests__/reader.jest.test.ts b/src/rulesets/__tests__/reader.jest.test.ts
index 7997affb..2bb03601 100644
--- a/src/rulesets/__tests__/reader.jest.test.ts
+++ b/src/rulesets/__tests__/reader.jest.test.ts
@@ -363,6 +363,15 @@ describe('Rulesets reader', () => {
     expect(rules['oas3-valid-rule'].formats).toEqual(['oas3']);
   });
 
+  it('should include parserOptions', async () => {
+    const { parserOptions } = await readRuleset(path.join(__dirname, '__fixtures__/parser-options-ruleset.json'));
+
+    expect(parserOptions).toStrictEqual({
+      duplicateKeys: 'warn',
+      incompatibleValues: 'off',
+    });
+  });
+
   it('given spectral:oas ruleset, should not pick up unrecommended rules', () => {
     return expect(readRuleset('spectral:oas')).resolves.toEqual(
       expect.objectContaining({
diff --git a/src/rulesets/__tests__/validation.test.ts b/src/rulesets/__tests__/validation.test.ts
index 1018ce61..84f0b504 100644
--- a/src/rulesets/__tests__/validation.test.ts
+++ b/src/rulesets/__tests__/validation.test.ts
@@ -1068,6 +1068,41 @@ describe('Ruleset Validation', () => {
       });
     });
   });
+
+  describe('parser options validation', () => {
+    it('recognizes valid options', () => {
+      expect(
+        assertValidRuleset.bind(null, {
+          extends: [],
+          parserOptions: {
+            incompatibleValues: 'warn',
+          },
+        }),
+      ).not.toThrow();
+
+      expect(
+        assertValidRuleset.bind(null, {
+          extends: [],
+          parserOptions: {
+            incompatibleValues: 2,
+            duplicateKeys: 'hint',
+          },
+        }),
+      ).not.toThrow();
+    });
+
+    it('given invalid values, throws', () => {
+      expect(
+        assertValidRuleset.bind(null, {
+          extends: [],
+          parserOptions: {
+            incompatibleValues: 5,
+            duplicateKeys: 'foo',
+          },
+        }),
+      ).toThrow(ValidationError);
+    });
+  });
 });
 
 describe('Function Validation', () => {
diff --git a/test-harness/scenarios/severity/fail-on-error.oas3.scenario b/test-harness/scenarios/severity/fail-on-error.oas3.scenario
index d6101100..e436501d 100644
--- a/test-harness/scenarios/severity/fail-on-error.oas3.scenario
+++ b/test-harness/scenarios/severity/fail-on-error.oas3.scenario
@@ -31,10 +31,10 @@ OpenAPI 3.x detected
   7:9   warning  operation-description         Operation `description` must be present and non-empty string.
   7:9   warning  operation-tags                Operation should have non-empty `tags` array.
   8:20    error  operation-operationId-unique  Every operation must have a unique `operationId`.
-  10:9  warning  parser                        Mapping key must be a string scalar rather than number
+  10:9    error  parser                        Mapping key must be a string scalar rather than number
  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.
  13:20    error  operation-operationId-unique  Every operation must have a unique `operationId`.
-  15:9  warning  parser                        Mapping key must be a string scalar rather than number
+  15:9    error  parser                        Mapping key must be a string scalar rather than number
 
-✖ 11 problems (2 errors, 9 warnings, 0 infos, 0 hints)
+✖ 11 problems (4 errors, 7 warnings, 0 infos, 0 hints)