Repository: stoplightio/spectral
Pull request #1071 (2020-04-07 19:16:11)

fix: improve schema validation
Fixes https://github.com/stoplightio/spectral/issues/403

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No

Besides addressing 403, it also improves the general readability and consistency of error messages generated by `schema` function.



marbemac: Prove it - let's see that before and after screenshot for whatever cases were improved :P.
P0lip: Given 
```yaml
openapi: 3.0.1
info:
  title: Example $ref error
  version: 1.0.0
paths:
  /user:
    get:
      operationId: getUser
      responses:
        "200":
          description: An Example
          content:
            application/json:
              schema:
                type: object
                properties:
                  user_id: 12345
```

5.3.0
![image](https://user-images.githubusercontent.com/9273484/78710712-18c49800-7916-11ea-8764-30083d4dfcd6.png)


PR
![image](https://user-images.githubusercontent.com/9273484/78710651-fdf22380-7915-11ea-86d3-4cfce02cbf2f.png)

Given
```yaml
openapi: 3.0.1
info:
  title: Example $ref error
  version: 1.0.0
paths:
  /user:
    get:
      operationId: getUser
      responses:
        "200":
          description: An Example
          content:
            application/json:
              schema:
                type: object
                properties:
```

5.3.0
![image](https://user-images.githubusercontent.com/9273484/78710845-532e3500-7916-11ea-90a4-8ae50b2c4ecc.png)

PR
![image](https://user-images.githubusercontent.com/9273484/78710828-4ad5fa00-7916-11ea-819f-ca081a972d81.png)

Given
```yaml
openapi: 3.0.1
info:
  title: Example $ref error
  version: 1.0.0
paths:
  /user:
    get:
      operationId: getUser
      responses:
        "200":
          description: An Example
          parameters:
```

5.3.0
![image](https://user-images.githubusercontent.com/9273484/78710894-650fd800-7916-11ea-8735-143ba704eb70.png)

PR
![image](https://user-images.githubusercontent.com/9273484/78710921-6e00a980-7916-11ea-8313-f6d28d2d4d47.png)

Given

```yaml
openapi: 3.0.1
info:
  title: Example $ref error
  version: 1.0.0
paths:
  /user:
    get:
      operationId: getUser
      parameters:
        - type: string
          name: module_id
          in: path
          required: true
          schema:
            type: string
          description: Module ID
      responses:
```

5.3.0
![image](https://user-images.githubusercontent.com/9273484/78711089-b750f900-7916-11ea-8ac1-c4b74727b701.png)

PR
![image](https://user-images.githubusercontent.com/9273484/78711065-ad2efa80-7916-11ea-98ff-5771385be75f.png)


Etc, etc.
philsturgeon: @P0lip this is amazing! It's the biggest issue we have with Spectral and it looks like you've nailed it.

I know it might feel annoying and redundant, but can you make a test harness for each of those examples you put in? As it's such a complicated and important thing, I want to smother it with coverage so it doesn't rear its ugly head again.
P0lip: @nulltoken @philsturgeon do you have any additional comments or suggestions?
nulltoken: > @nulltoken @philsturgeon do you have any additional comments or suggestions?

To be honest, I've read the code of `prepareResults` at least three times, I still have no idea what it exactly does. Would it be very time consuming to export it and cover it with tests putting under the lights the input AJV-like error objects and the expected results?

By taking a look at the results of the integration tests, I can see that it indeed works, however, I'd really like to get a better understanding at how it works and solves the initial issue.

If we ever need to extend/patch this function, I'd feel more at ease with a set of tight unit tests that precisely express the behavioral contract of the function.
P0lip: > To be honest, I've read the code of prepareResults at least three times, I still have no idea what it exactly does. Would it be very time consuming to export it and cover it with tests putting under the lights the input AJV-like error objects and the expected results?

Yep, I'll prepare errors. It'll certainly make it easier for everyone to reason about that function when you show what kind of errors it filters out and how it does it.
P0lip: @nulltoken added some. LMK if it's more clear now. In general all it does is removal of "evil" errors.
P0lip: @nulltoken done!

Patch:
diff --git a/src/functions/schema.ts b/src/functions/schema.ts
index 03d1486a..7eab525f 100644
--- a/src/functions/schema.ts
+++ b/src/functions/schema.ts
@@ -5,7 +5,7 @@ import { ValidateFunction } from 'ajv';
 import * as jsonSpecv4 from 'ajv/lib/refs/json-schema-draft-04.json';
 import * as jsonSpecv6 from 'ajv/lib/refs/json-schema-draft-06.json';
 import { IOutputError } from 'better-ajv-errors';
-import { escapeRegExp } from 'lodash';
+import { capitalize, escapeRegExp } from 'lodash';
 import { IFunction, IFunctionResult, IRule, JSONSchema, RuleFunction } from '../types';
 const oasFormatValidator = require('ajv-oai/lib/format-validator');
 const betterAjvErrors = require('better-ajv-errors/lib/modern');
@@ -14,6 +14,10 @@ export interface ISchemaOptions {
   schema: object;
   // The oasVersion, either 2 or 3 for OpenAPI Spec versions, could also be 3.1 or a larger number if there's a need for it, otherwise JSON Schema
   oasVersion?: Optional<number>;
+  allErrors?: boolean;
+
+  // this is used by oasDocumentSchema function, to be removed once we sort out
+  prepareResults?(errors: AJV.ErrorObject[]): void;
 }
 
 export type SchemaRule = IRule<RuleFunction.SCHEMA, ISchemaOptions>;
@@ -36,7 +40,7 @@ const logger = {
 
 const ajvInstances = {};
 
-function getAjv(oasVersion?: Optional<number>): AJV.Ajv {
+function getAjv(oasVersion: Optional<number>, allErrors: Optional<boolean>): AJV.Ajv {
   const type: string = oasVersion && oasVersion >= 2 ? 'oas' + oasVersion : 'jsonschema';
   if (typeof ajvInstances[type] !== 'undefined') {
     return ajvInstances[type];
@@ -45,6 +49,7 @@ function getAjv(oasVersion?: Optional<number>): AJV.Ajv {
   const ajvOpts: AJV.Options = {
     meta: true, // Add default meta schemas (draft 7 at the moment)
     schemaId: 'auto',
+    allErrors,
     jsonPointers: true,
     unknownFormats: 'ignore',
     nullable: oasVersion === 3, // Support nullable for OAS3
@@ -81,8 +86,8 @@ function getSchemaId(schemaObj: JSONSchema): void | string {
 }
 
 const validators = new (class extends WeakMap<JSONSchema, ValidateFunction> {
-  public get(schemaObj: JSONSchema, oasVersion?: Optional<number>) {
-    const ajv = getAjv(oasVersion);
+  public get({ schema: schemaObj, oasVersion, allErrors }: ISchemaOptions) {
+    const ajv = getAjv(oasVersion, allErrors);
     const schemaId = getSchemaId(schemaObj);
     let validator = schemaId !== void 0 ? ajv.getSchema(schemaId) : void 0;
     if (validator !== void 0) {
@@ -100,12 +105,16 @@ const validators = new (class extends WeakMap<JSONSchema, ValidateFunction> {
   }
 })();
 
-const replaceProperty = (substring: string, group1: string) => {
-  if (group1) {
-    return 'property ';
+const replaceProperty = (
+  substring: string,
+  potentialProperty: Optional<number | string>,
+  propertyName: Optional<string>,
+) => {
+  if (typeof potentialProperty === 'string' && propertyName !== void 0) {
+    return `Property \`${propertyName}\``;
   }
 
-  return '{{property|gravis|append-property|optional-typeof}}';
+  return '{{property|gravis|append-property|optional-typeof|capitalize}}';
 };
 
 const cleanAJVErrorMessage = (message: string, path: Optional<string>, suggestion: Optional<string>, type: string) => {
@@ -113,13 +122,15 @@ const cleanAJVErrorMessage = (message: string, path: Optional<string>, suggestio
 
   if (path) {
     cleanMessage = message.replace(
-      new RegExp(`^${escapeRegExp(decodePointerFragment(path))}:?\\s*(Property\\s+)?`),
+      new RegExp(`^${escapeRegExp(decodePointerFragment(path))}:?\\s*(?:(Property\\s+)([^\\s]+))?`),
       replaceProperty,
     );
   } else if (cleanMessage.startsWith(':')) {
     cleanMessage = cleanMessage.replace(/:\s*/, replaceProperty);
+  } else if (cleanMessage.startsWith('Property ')) {
+    cleanMessage = cleanMessage.replace(/(Property\s+)([^\s]+)/, replaceProperty);
   } else {
-    cleanMessage = `${type} ${cleanMessage}`;
+    cleanMessage = `${capitalize(type)} ${cleanMessage}`;
   }
 
   return `${cleanMessage.replace(/['"]/g, '`')}${
@@ -132,21 +143,24 @@ export const schema: IFunction<ISchemaOptions> = (targetVal, opts, paths) => {
 
   const path = paths.target || paths.given;
 
-  if (targetVal === void 0)
+  if (targetVal === void 0) {
     return [
       {
         path,
-        message: `{{property|double-quotes|append-property}}does not exist`,
+        message: `{{property|gravis|append-property}}does not exist`,
       },
     ];
+  }
 
   // we already access a resolved object in src/functions/schema-path.ts
   const { schema: schemaObj } = opts;
 
   try {
     // we used the compiled validation now, hence this lookup here (see the logic above for more info)
-    const validator = validators.get(schemaObj, opts.oasVersion);
+    const validator = validators.get(opts);
     if (!validator(targetVal) && validator.errors) {
+      opts.prepareResults?.(validator.errors);
+
       try {
         results.push(
           ...(betterAjvErrors(schemaObj, targetVal, validator.errors, { format: 'js' }) as IAJVOutputError[]).map(
diff --git a/src/rulesets/message.ts b/src/rulesets/message.ts
index c0937532..969c1f5f 100644
--- a/src/rulesets/message.ts
+++ b/src/rulesets/message.ts
@@ -1,5 +1,5 @@
 import { Segment } from '@stoplight/types';
-import { isObject } from 'lodash';
+import { capitalize, isObject } from 'lodash';
 import { Replacer } from '../utils/replacer';
 
 export interface IMessageVars {
@@ -17,6 +17,7 @@ const MessageReplacer = new Replacer<IMessageVars>(2);
 MessageReplacer.addTransformer('double-quotes', (id, value) => (value ? `"${value}"` : ''));
 MessageReplacer.addTransformer('single-quotes', (id, value) => (value ? `'${value}'` : ''));
 MessageReplacer.addTransformer('gravis', (id, value) => (value ? `\`${value}\`` : ''));
+MessageReplacer.addTransformer('capitalize', (id, value) => capitalize(String(value)));
 
 MessageReplacer.addTransformer('append-property', (id, value) => (value ? `${value} property ` : ''));
 MessageReplacer.addTransformer('optional-typeof', (id, value, values) =>
diff --git a/src/rulesets/oas/functions/oasDocumentSchema.ts b/src/rulesets/oas/functions/oasDocumentSchema.ts
new file mode 100644
index 00000000..25bb71be
--- /dev/null
+++ b/src/rulesets/oas/functions/oasDocumentSchema.ts
@@ -0,0 +1,72 @@
+import * as AJV from 'ajv';
+import { ISchemaOptions } from '../../../functions/schema';
+import { IFunction, IFunctionContext, IFunctionResult } from '../../../types';
+
+function shouldIgnoreError(error: AJV.ErrorObject) {
+  return (
+    // oneOf is a fairly error as we have 2 options to choose from for most of the time.
+    error.keyword === 'oneOf' ||
+    // the required $ref is entirely useless, since oas-schema rules operate on resolved content, so there won't be any $refs in the document
+    (error.keyword === 'required' && (error.params as AJV.RequiredParams).missingProperty === '$ref')
+  );
+}
+
+// this is supposed to cover edge cases we need to cover manually, when it's impossible to detect the most appropriate error, i.e. oneOf consisting of more than 3 members, etc.
+// note,  more errors can be included if certain messages reported by AJV are not quite meaningful
+const ERROR_MAP = [
+  {
+    path: /^components\/securitySchemes\/[^/]+$/,
+    message: 'Invalid security scheme',
+  },
+  {
+    path: /^securityDefinitions\/[^/]+$/,
+    message: 'Invalid security definition',
+  },
+];
+
+// The function removes irrelevant (aka misleading, confusing, useless, whatever you call it) errors.
+// There are a few exceptions, i.e. security components I covered manually,
+// yet apart from them we usually deal with a relatively simple scenario that can be literally expressed as: "either proper value of $ref property".
+// The $ref part is never going to be interesting for us, because both oas-schema rules operate on resolved content, so we won't have any $refs left.
+// As you can see, what we deal here wit is actually not really oneOf anymore - it's always the first member of oneOf we match against.
+// That being said, we always strip both oneOf and $ref, since we are always interested in the first error.
+export function prepareResults(errors: AJV.ErrorObject[]) {
+  for (let i = 0; i < errors.length; i++) {
+    const error = errors[i];
+
+    if (i + 1 < errors.length && errors[i + 1].dataPath === error.dataPath) {
+      errors.splice(i + 1, 1);
+      i--;
+    } else if (i > 0 && shouldIgnoreError(error) && errors[i - 1].dataPath.startsWith(error.dataPath)) {
+      errors.splice(i, 1);
+      i--;
+    }
+  }
+}
+
+function applyManualReplacements(errors: IFunctionResult[]) {
+  for (const error of errors) {
+    if (error.path === void 0) continue;
+
+    const joinedPath = error.path.join('/');
+
+    for (const mappedError of ERROR_MAP) {
+      if (mappedError.path.test(joinedPath)) {
+        error.message = mappedError.message;
+        break;
+      }
+    }
+  }
+}
+
+export const oasDocumentSchema: IFunction<ISchemaOptions> = function(this: IFunctionContext, targetVal, opts, ...args) {
+  const errors = this.functions.schema.call(this, targetVal, { ...opts, prepareResults }, ...args);
+
+  if (Array.isArray(errors)) {
+    applyManualReplacements(errors);
+  }
+
+  return errors;
+};
+
+export default oasDocumentSchema;
diff --git a/src/rulesets/oas/index.json b/src/rulesets/oas/index.json
index 9bd16ce8..7d5719b1 100644
--- a/src/rulesets/oas/index.json
+++ b/src/rulesets/oas/index.json
@@ -1,6 +1,7 @@
 {
   "formats": ["oas2", "oas3"],
   "functions": [
+    "oasDocumentSchema",
     "oasOp2xxResponse",
     "oasOpFormDataConsumeCheck",
     "oasOpIdUnique",
@@ -579,15 +580,16 @@
     },
     "oas2-schema": {
       "description": "Validate structure of OpenAPI v2 specification.",
-      "message": "{{error}}",
+      "message": "{{error}}.",
       "recommended": true,
       "formats": ["oas2"],
       "severity": 0,
       "type": "validation",
       "given": "$",
       "then": {
-        "function": "schema",
+        "function": "oasDocumentSchema",
         "functionOptions": {
+          "allErrors": true,
           "schema": {
             "$ref": "./schemas/schema.oas2.json"
           }
@@ -837,15 +839,16 @@
     },
     "oas3-schema": {
       "description": "Validate structure of OpenAPI v3 specification.",
-      "message": "{{error}}",
+      "message": "{{error}}.",
       "severity": 0,
       "formats": ["oas3"],
       "recommended": true,
       "type": "validation",
       "given": "$",
       "then": {
-        "function": "schema",
+        "function": "oasDocumentSchema",
         "functionOptions": {
+          "allErrors": true,
           "schema": {
             "$ref": "./schemas/schema.oas3.json"
           }


Test patch:
diff --git a/src/__tests__/linter.jest.test.ts b/src/__tests__/linter.jest.test.ts
index 9f491fe7..8949917f 100644
--- a/src/__tests__/linter.jest.test.ts
+++ b/src/__tests__/linter.jest.test.ts
@@ -5,7 +5,10 @@ import { isOpenApiv3 } from '../formats';
 import { httpAndFileResolver } from '../resolvers/http-and-file';
 import { Spectral } from '../spectral';
 
+import { functions } from '../functions';
 import { readRuleset } from '../rulesets';
+import { setFunctionContext } from '../rulesets/evaluators';
+import oasDocumentSchema from '../rulesets/oas/functions/oasDocumentSchema';
 import { IRuleset, RulesetExceptionCollection } from '../types/ruleset';
 
 const customFunctionOASRuleset = path.join(__dirname, './__fixtures__/custom-functions-oas-ruleset.json');
@@ -457,10 +460,17 @@ console.log(this.cache.get('test') || this.cache.set('test', []).get('test'));
 
         const rules = {
           'strings-maxLength': testRuleset.rules['strings-maxLength'],
-          'oas3-schema': testRuleset.rules['oas3-schema'],
+          'oas3-schema': {
+            ...testRuleset.rules['oas3-schema'],
+            then: {
+              ...testRuleset.rules['oas3-schema'].then,
+              function: 'oasDocumentSchema',
+            },
+          },
         };
 
         spectral.setRuleset({ rules, exceptions: {}, functions: {} });
+        spectral.setFunctions({ oasDocumentSchema: setFunctionContext({ functions }, oasDocumentSchema) });
 
         const first = await spectral.run(document, opts);
 
@@ -476,6 +486,7 @@ console.log(this.cache.get('test') || this.cache.set('test', []).get('test'));
         const exceptions = extractExceptionFrom(testRuleset, 'strings-maxLength', 0);
 
         spectral.setRuleset({ rules, exceptions, functions: {} });
+        spectral.setFunctions({ oasDocumentSchema: setFunctionContext({ functions }, oasDocumentSchema) });
 
         const second = await spectral.run(document, opts);
 
@@ -492,10 +503,17 @@ console.log(this.cache.get('test') || this.cache.set('test', []).get('test'));
 
         const rules = {
           'no-yaml-remote-reference': testRuleset.rules['no-yaml-remote-reference'],
-          'oas3-schema': testRuleset.rules['oas3-schema'],
+          'oas3-schema': {
+            ...testRuleset.rules['oas3-schema'],
+            then: {
+              ...testRuleset.rules['oas3-schema'].then,
+              function: 'oasDocumentSchema',
+            },
+          },
         };
 
         spectral.setRuleset({ rules, exceptions: {}, functions: {} });
+        spectral.setFunctions({ oasDocumentSchema: setFunctionContext({ functions }, oasDocumentSchema) });
 
         const first = await spectral.run(document, opts);
 
@@ -511,6 +529,7 @@ console.log(this.cache.get('test') || this.cache.set('test', []).get('test'));
         const exceptions = extractExceptionFrom(testRuleset, 'no-yaml-remote-reference', 1);
 
         spectral.setRuleset({ rules, exceptions, functions: {} });
+        spectral.setFunctions({ oasDocumentSchema: setFunctionContext({ functions }, oasDocumentSchema) });
 
         const second = await spectral.run(document, opts);
 
diff --git a/src/__tests__/linter.test.ts b/src/__tests__/linter.test.ts
index e1fdc221..a7b3b356 100644
--- a/src/__tests__/linter.test.ts
+++ b/src/__tests__/linter.test.ts
@@ -158,13 +158,16 @@ describe('linter', () => {
       }) as RuleCollection,
     );
 
+    // @ts-ignore
+    spectral.rules['oas3-schema'].then.function = 'oasDocumentSchema';
+
     const result = await spectral.run(invalidSchema);
 
     expect(result).toEqual([
       expect.objectContaining({
         code: 'oas3-schema',
-        message: `\`200\` property should have required property \`$ref\``,
-        path: ['paths', '/pets', 'get', 'responses', '200'],
+        message: 'Property `type` is not expected to be here.',
+        path: ['paths', '/pets', 'get', 'responses', '200', 'headers', 'header-1'],
       }),
       expect.objectContaining({
         code: 'invalid-ref',
@@ -194,8 +197,8 @@ describe('linter', () => {
       expect.arrayContaining([
         expect.objectContaining({
           code: 'oas3-schema',
-          message: `\`200\` property should have required property \`$ref\``,
-          path: ['paths', '/pets', 'get', 'responses', '200'],
+          message: 'Property `type` is not expected to be here.',
+          path: ['paths', '/pets', 'get', 'responses', '200', 'headers', 'header-1'],
         }),
       ]),
     );
@@ -621,8 +624,8 @@ responses:: !!foo
       }),
       expect.objectContaining({
         code: 'oas3-schema',
-        message: `\`200\` property should have required property \`$ref\``,
-        path: ['paths', '/pets', 'get', 'responses', '200'],
+        message: 'Property `type` is not expected to be here.',
+        path: ['paths', '/pets', 'get', 'responses', '200', 'headers', 'header-1'],
       }),
       expect.objectContaining({
         code: 'invalid-ref',
@@ -1028,8 +1031,8 @@ responses:: !!foo
       expect.arrayContaining([
         expect.objectContaining({
           code: 'oas3-schema',
-          message: 'Schema error at #/paths/~1pets/get/responses/200',
-          path: ['paths', '/pets', 'get', 'responses', '200'],
+          message: 'Schema error at #/paths/~1pets/get/responses/200/headers/header-1',
+          path: ['paths', '/pets', 'get', 'responses', '200', 'headers', 'header-1'],
         }),
       ]),
     );
diff --git a/src/cli/services/__tests__/__fixtures__/draft-ref.oas2.json b/src/cli/services/__tests__/__fixtures__/draft-ref.oas2.json
index aed1ba3d..594280d8 100644
--- a/src/cli/services/__tests__/__fixtures__/draft-ref.oas2.json
+++ b/src/cli/services/__tests__/__fixtures__/draft-ref.oas2.json
@@ -5,6 +5,7 @@
     "$ref": "./refs/info.json#/definitions/info"
   },
   "paths": {
-    "test": {}
+    "/test": {},
+    "foo": {}
   }
 }
diff --git a/src/cli/services/__tests__/linter.test.ts b/src/cli/services/__tests__/linter.test.ts
index 505dcb79..2e8f31b1 100644
--- a/src/cli/services/__tests__/linter.test.ts
+++ b/src/cli/services/__tests__/linter.test.ts
@@ -225,7 +225,7 @@ describe('Linter service', () => {
           }),
           expect.objectContaining({
             code: 'oas3-schema',
-            path: ['paths', '/pets', 'get', 'responses', '200'],
+            path: ['paths', '/pets', 'get', 'responses', '200', 'headers', 'header-1'],
             source: join(process.cwd(), 'src/__tests__/__fixtures__/petstore.invalid-schema.oas3.json'),
           }),
           expect.objectContaining({
@@ -397,7 +397,7 @@ describe('Linter service', () => {
           }),
           expect.objectContaining({
             code: 'oas3-schema',
-            path: ['paths', '/pets', 'get', 'responses', '200'],
+            path: ['paths', '/pets', 'get', 'responses', '200', 'headers', 'header-1'],
             source: join(process.cwd(), 'src/__tests__/__fixtures__/petstore.invalid-schema.oas3.json'),
           }),
         ]),
@@ -574,7 +574,23 @@ describe('Linter service', () => {
         }),
         expect.objectContaining({
           code: 'oas2-schema',
-          message: 'property foo is not expected to be here',
+          message: 'Property `foo` is not expected to be here.',
+          path: ['paths'],
+          range: {
+            end: {
+              character: 13,
+              line: 8,
+            },
+            start: {
+              character: 10,
+              line: 6,
+            },
+          },
+          source: expect.stringContaining('__tests__/__fixtures__/draft-ref.oas2.json'),
+        }),
+        expect.objectContaining({
+          code: 'oas2-schema',
+          message: 'Property `foo` is not expected to be here.',
           path: ['definitions', 'info'],
           range: {
             end: {
@@ -604,6 +620,22 @@ describe('Linter service', () => {
           },
           source: expect.stringContaining('__tests__/__fixtures__/refs/info.json'),
         }),
+        expect.objectContaining({
+          code: 'oas2-schema',
+          message: '`description` property type should be string.',
+          path: ['definitions', 'info', 'description'],
+          range: {
+            end: {
+              character: 22,
+              line: 5,
+            },
+            start: {
+              character: 21,
+              line: 5,
+            },
+          },
+          source: expect.stringContaining('__tests__/__fixtures__/refs/info.json'),
+        }),
       ]);
     });
 
@@ -625,7 +657,7 @@ describe('Linter service', () => {
         }),
         expect.objectContaining({
           code: 'oas2-schema',
-          message: `object should have required property \`title\``,
+          message: `Object should have required property \`title\`.`,
           path: [],
           range: {
             end: {
@@ -655,6 +687,22 @@ describe('Linter service', () => {
           },
           source: expect.stringContaining('__tests__/__fixtures__/refs/contact.json'),
         }),
+        expect.objectContaining({
+          code: 'oas2-schema',
+          message: 'Property `response` is not expected to be here.',
+          path: ['paths', '/test', 'get'],
+          range: {
+            end: {
+              character: 7,
+              line: 5,
+            },
+            start: {
+              character: 13,
+              line: 3,
+            },
+          },
+          source: expect.stringContaining('__tests__/__fixtures__/refs/paths.json'),
+        }),
         expect.objectContaining({
           code: 'operation-description',
           message: 'Operation `description` must be present and non-empty string.',
diff --git a/src/functions/__tests__/schema-path.test.ts b/src/functions/__tests__/schema-path.test.ts
index 3addc359..309250d4 100644
--- a/src/functions/__tests__/schema-path.test.ts
+++ b/src/functions/__tests__/schema-path.test.ts
@@ -64,7 +64,7 @@ describe('schema-path', () => {
     expect(runSchemaPath(target, fieldToCheck, path)).toEqual([
       {
         path: ['example'],
-        message: 'object should have required property `url`',
+        message: 'Object should have required property `url`',
       },
     ]);
   });
@@ -79,7 +79,7 @@ describe('schema-path', () => {
     expect(runSchemaPath(target, fieldToCheck, path)).toEqual([
       {
         path: ['example'],
-        message: '{{property|gravis|append-property|optional-typeof}}type should be string',
+        message: '{{property|gravis|append-property|optional-typeof|capitalize}}type should be string',
       },
     ]);
   });
@@ -109,11 +109,11 @@ describe('schema-path', () => {
     };
     expect(runSchemaPath(target, '$.examples.*', path)).toEqual([
       {
-        message: '{{property|gravis|append-property|optional-typeof}}type should be string',
+        message: '{{property|gravis|append-property|optional-typeof|capitalize}}type should be string',
         path: ['examples', 'application/json', 'id'],
       },
       {
-        message: '{{property|gravis|append-property|optional-typeof}}type should be string',
+        message: '{{property|gravis|append-property|optional-typeof|capitalize}}type should be string',
         path: ['examples', 'application/yaml', 'id'],
       },
     ]);
@@ -130,7 +130,7 @@ describe('schema-path', () => {
 
     expect(runSchemaPath(target, fieldToCheck, path)).toEqual([
       {
-        message: '{{property|gravis|append-property|optional-typeof}}format should match format `url`',
+        message: '{{property|gravis|append-property|optional-typeof|capitalize}}format should match format `url`',
         path: ['example'],
       },
     ]);
@@ -148,7 +148,7 @@ describe('schema-path', () => {
       };
       expect(runSchemaPath(target, invalidFieldToCheck, path)).toEqual([
         {
-          message: '{{property|double-quotes|append-property}}does not exist',
+          message: '{{property|gravis|append-property}}does not exist',
           path: ['nonsense'],
         },
       ]);
diff --git a/src/functions/__tests__/schema.test.ts b/src/functions/__tests__/schema.test.ts
index 68ba2e05..418864d3 100644
--- a/src/functions/__tests__/schema.test.ts
+++ b/src/functions/__tests__/schema.test.ts
@@ -15,7 +15,7 @@ describe('schema', () => {
 
       expect(runSchema('', testSchema)).toEqual([
         {
-          message: '{{property|gravis|append-property|optional-typeof}}type should be number',
+          message: '{{property|gravis|append-property|optional-typeof|capitalize}}type should be number',
           path: [],
         },
       ]);
@@ -28,7 +28,7 @@ describe('schema', () => {
 
       expect(runSchema(0, testSchema)).toEqual([
         {
-          message: `{{property|gravis|append-property|optional-typeof}}type should be string`,
+          message: `{{property|gravis|append-property|optional-typeof|capitalize}}type should be string`,
           path: [],
         },
       ]);
@@ -41,7 +41,7 @@ describe('schema', () => {
 
       expect(runSchema(false, testSchema)).toEqual([
         {
-          message: `{{property|gravis|append-property|optional-typeof}}type should be string`,
+          message: `{{property|gravis|append-property|optional-typeof|capitalize}}type should be string`,
           path: [],
         },
       ]);
@@ -54,7 +54,7 @@ describe('schema', () => {
 
       expect(runSchema(null, testSchema)).toEqual([
         {
-          message: `{{property|gravis|append-property|optional-typeof}}type should be string`,
+          message: `{{property|gravis|append-property|optional-typeof|capitalize}}type should be string`,
           path: [],
         },
       ]);
@@ -95,7 +95,7 @@ describe('schema', () => {
       const input = { foo: 'bar' };
       expect(runSchema(input, testSchema)).toEqual([
         expect.objectContaining({
-          message: '{{property|gravis|append-property|optional-typeof}}type should be array',
+          message: '{{property|gravis|append-property|optional-typeof|capitalize}}type should be array',
           path: [],
         }),
       ]);
@@ -105,7 +105,8 @@ describe('schema', () => {
       const input = ['1', '2'];
       expect(runSchema(input, testSchema)).toEqual([
         expect.objectContaining({
-          message: '{{property|gravis|append-property|optional-typeof}}maxItems should NOT have more than 1 items',
+          message:
+            '{{property|gravis|append-property|optional-typeof|capitalize}}maxItems should NOT have more than 1 items',
           path: [],
         }),
       ]);
@@ -141,7 +142,7 @@ describe('schema', () => {
         ),
       ).toEqual([
         {
-          message: `{{property|gravis|append-property|optional-typeof}}type should be string`,
+          message: `{{property|gravis|append-property|optional-typeof|capitalize}}type should be string`,
           path: ['foo', 'bar'],
         },
       ]);
@@ -158,7 +159,7 @@ describe('schema', () => {
         ),
       ).toEqual([
         {
-          message: 'property baz is not expected to be here',
+          message: 'Property `baz` is not expected to be here',
           path: ['foo'],
         },
       ]);
@@ -175,7 +176,7 @@ describe('schema', () => {
       const input = 'not an email';
       expect(runSchema(input, testSchema)).toEqual([
         expect.objectContaining({
-          message: '{{property|gravis|append-property|optional-typeof}}format should match format `email`',
+          message: '{{property|gravis|append-property|optional-typeof|capitalize}}format should match format `email`',
           path: [],
         }),
       ]);
@@ -213,7 +214,7 @@ describe('schema', () => {
     expect(runSchema(2, testSchema)).toEqual([
       {
         path: [],
-        message: `{{property|gravis|append-property|optional-typeof}}type should be string`,
+        message: `{{property|gravis|append-property|optional-typeof|capitalize}}type should be string`,
       },
     ]);
     expect(runSchema('a', testSchema2)).toEqual([]);
@@ -233,7 +234,7 @@ describe('schema', () => {
     expect(runSchema(2, testSchema)).toEqual([
       {
         path: [],
-        message: `{{property|gravis|append-property|optional-typeof}}type should be string`,
+        message: `{{property|gravis|append-property|optional-typeof|capitalize}}type should be string`,
       },
     ]);
     expect(runSchema('a', testSchema2)).toEqual([]);
@@ -259,7 +260,7 @@ describe('schema', () => {
       it('reports pretty enum errors for a string', () => {
         expect(runSchema('baz', testSchema)).toEqual([
           {
-            message: `string should be equal to one of the allowed values: foo, bar. Did you mean bar?`,
+            message: `String should be equal to one of the allowed values: foo, bar. Did you mean bar?`,
             path: [],
           },
         ]);
@@ -268,7 +269,7 @@ describe('schema', () => {
       it('reports pretty enum errors for a number', () => {
         expect(runSchema(2, testSchema)).toEqual([
           {
-            message: `{{property|gravis|append-property|optional-typeof}}type should be string`,
+            message: `{{property|gravis|append-property|optional-typeof|capitalize}}type should be string`,
             path: [],
           },
         ]);
@@ -285,7 +286,7 @@ describe('schema', () => {
       it('reports pretty enum errors for a string', () => {
         expect(runSchema('baz', testSchema)).toEqual([
           {
-            message: '{{property|gravis|append-property|optional-typeof}}type should be integer',
+            message: '{{property|gravis|append-property|optional-typeof|capitalize}}type should be integer',
             path: [],
           },
         ]);
@@ -294,7 +295,7 @@ describe('schema', () => {
       it('reports pretty enum errors for a number', () => {
         expect(runSchema(2, testSchema)).toEqual([
           {
-            message: `number should be equal to one of the allowed values: 1, 3, 5, 10, 12`,
+            message: `Number should be equal to one of the allowed values: 1, 3, 5, 10, 12`,
             path: [],
           },
         ]);
@@ -311,9 +312,19 @@ describe('schema', () => {
 
     expect(runSchema('three', testSchema)).toEqual([
       {
-        message: `string should be equal to one of the allowed values: foo, bar`,
+        message: `String should be equal to one of the allowed values: foo, bar`,
         path: [],
       },
     ]);
   });
+
+  test('pretty-prints path-less property', () => {
+    const input = { foo: true };
+    expect(runSchema(input, { additionalProperties: false })).toEqual([
+      expect.objectContaining({
+        message: 'Property `foo` is not expected to be here',
+        path: [],
+      }),
+    ]);
+  });
 });
diff --git a/src/rulesets/oas/__tests__/oas2-schema.ts b/src/rulesets/oas/__tests__/oas2-schema.ts
index 12936ccb..de3284c4 100644
--- a/src/rulesets/oas/__tests__/oas2-schema.ts
+++ b/src/rulesets/oas/__tests__/oas2-schema.ts
@@ -1,21 +1,30 @@
+import { functions } from '../../../functions';
 import { RuleType, Spectral } from '../../../spectral';
+import { setFunctionContext } from '../../evaluators';
+import oasDocumentSchema from '../functions/oasDocumentSchema';
 import * as ruleset from '../index.json';
 import * as oas2Schema from '../schemas/schema.oas2.json';
 
 describe('oas2-schema', () => {
-  const s = new Spectral();
-  s.registerFormat('oas2', () => true);
-  s.setRules({
-    'oas2-schema': Object.assign({}, ruleset.rules['oas2-schema'], {
-      recommended: true,
-      type: RuleType[ruleset.rules['oas2-schema'].type],
-      then: {
-        ...ruleset.rules['oas2-schema'].then,
-        functionOptions: {
-          schema: oas2Schema,
+  let s: Spectral;
+
+  beforeEach(() => {
+    s = new Spectral();
+    s.registerFormat('oas2', () => true);
+    s.setFunctions({ oasDocumentSchema: setFunctionContext({ functions }, oasDocumentSchema) });
+    s.setRules({
+      'oas2-schema': Object.assign({}, ruleset.rules['oas2-schema'], {
+        recommended: true,
+        type: RuleType[ruleset.rules['oas2-schema'].type],
+        then: {
+          ...ruleset.rules['oas2-schema'].then,
+          functionOptions: {
+            ...ruleset.rules['oas2-schema'].then.functionOptions,
+            schema: oas2Schema,
+          },
         },
-      },
-    }),
+      }),
+    });
   });
 
   test('annotates with correct paths', async () => {
@@ -35,7 +44,7 @@ describe('oas2-schema', () => {
     expect(results).toEqual([
       {
         code: 'oas2-schema',
-        message: `\`get\` property should have required property \`responses\``,
+        message: `\`get\` property should have required property \`responses\`.`,
         path: ['paths', '/test', 'get'],
         range: {
           end: {
diff --git a/src/rulesets/oas/__tests__/oas2-valid-definition-example.ts b/src/rulesets/oas/__tests__/oas2-valid-definition-example.ts
index bc18d965..e1e44c1b 100644
--- a/src/rulesets/oas/__tests__/oas2-valid-definition-example.ts
+++ b/src/rulesets/oas/__tests__/oas2-valid-definition-example.ts
@@ -104,7 +104,7 @@ describe('oas2-valid-definition-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas2-valid-definition-example',
-        message: 'object should have required property `url`',
+        message: 'Object should have required property `url`',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
diff --git a/src/rulesets/oas/__tests__/oas2-valid-parameter-example.ts b/src/rulesets/oas/__tests__/oas2-valid-parameter-example.ts
index e3cd24c8..cd762581 100644
--- a/src/rulesets/oas/__tests__/oas2-valid-parameter-example.ts
+++ b/src/rulesets/oas/__tests__/oas2-valid-parameter-example.ts
@@ -127,7 +127,7 @@ describe('oas2-valid-parameter-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas2-valid-parameter-example',
-        message: 'object should have required property `url`',
+        message: 'Object should have required property `url`',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
diff --git a/src/rulesets/oas/__tests__/oas3-valid-oas-parameter-example.ts b/src/rulesets/oas/__tests__/oas3-valid-oas-parameter-example.ts
index 839896a3..00aebdb1 100644
--- a/src/rulesets/oas/__tests__/oas3-valid-oas-parameter-example.ts
+++ b/src/rulesets/oas/__tests__/oas3-valid-oas-parameter-example.ts
@@ -119,7 +119,7 @@ describe('oas3-valid-oas-parameter-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas3-valid-oas-parameter-example',
-        message: 'object should have required property `abc`',
+        message: 'Object should have required property `abc`',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
@@ -156,7 +156,7 @@ describe('oas3-valid-oas-parameter-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas3-valid-oas-parameter-example',
-        message: 'object should have required property `url`',
+        message: 'Object should have required property `url`',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
diff --git a/src/rulesets/oas/__tests__/oas3-valid-parameter-schema-example.ts b/src/rulesets/oas/__tests__/oas3-valid-parameter-schema-example.ts
index 8067d9b9..941ecdec 100644
--- a/src/rulesets/oas/__tests__/oas3-valid-parameter-schema-example.ts
+++ b/src/rulesets/oas/__tests__/oas3-valid-parameter-schema-example.ts
@@ -284,7 +284,7 @@ describe('oas3-valid-parameter-schema-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas3-valid-parameter-schema-example',
-        message: 'object should have required property `url`',
+        message: 'Object should have required property `url`',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
diff --git a/src/rulesets/oas/__tests__/oas3-valid-schema-example.ts b/src/rulesets/oas/__tests__/oas3-valid-schema-example.ts
index eff60701..95aef983 100644
--- a/src/rulesets/oas/__tests__/oas3-valid-schema-example.ts
+++ b/src/rulesets/oas/__tests__/oas3-valid-schema-example.ts
@@ -226,7 +226,7 @@ describe('oas3-valid-schema-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas3-valid-schema-example',
-        message: 'object should have required property `url`',
+        message: 'Object should have required property `url`',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
diff --git a/src/rulesets/oas/__tests__/templates/_oas-example.ts b/src/rulesets/oas/__tests__/templates/_oas-example.ts
index fd4b593e..b72598bc 100644
--- a/src/rulesets/oas/__tests__/templates/_oas-example.ts
+++ b/src/rulesets/oas/__tests__/templates/_oas-example.ts
@@ -158,7 +158,7 @@ export default (ruleName: string, path: string) => {
     expect(results).toEqual([
       expect.objectContaining({
         code: ruleName,
-        message: 'object should have required property `url`',
+        message: 'Object should have required property `url`',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
diff --git a/src/rulesets/oas/__tests__/templates/_schema-example.ts b/src/rulesets/oas/__tests__/templates/_schema-example.ts
index 2f7d698b..9c488455 100644
--- a/src/rulesets/oas/__tests__/templates/_schema-example.ts
+++ b/src/rulesets/oas/__tests__/templates/_schema-example.ts
@@ -212,7 +212,7 @@ export default (ruleName: string, path: string) => {
     expect(results).toEqual([
       expect.objectContaining({
         code: ruleName,
-        message: 'object should have required property `url`',
+        message: 'Object should have required property `url`',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
diff --git a/src/rulesets/oas/functions/__tests__/oasDocumentSchema.test.ts b/src/rulesets/oas/functions/__tests__/oasDocumentSchema.test.ts
new file mode 100644
index 00000000..1ff4ec2f
--- /dev/null
+++ b/src/rulesets/oas/functions/__tests__/oasDocumentSchema.test.ts
@@ -0,0 +1,347 @@
+import { DiagnosticSeverity } from '@stoplight/types';
+import { isOpenApiv2, isOpenApiv3, RuleType, Spectral } from '../../../..';
+import { functions } from '../../../../functions';
+import { setFunctionContext } from '../../../evaluators';
+import { rules } from '../../index.json';
+import oasDocumentSchema, { prepareResults } from '../oasDocumentSchema';
+
+import { ErrorObject } from 'ajv';
+import * as oas2Schema from '../../schemas/schema.oas2.json';
+import * as oas3Schema from '../../schemas/schema.oas3.json';
+
+describe('oasDocumentSchema', () => {
+  let s: Spectral;
+
+  beforeEach(() => {
+    s = new Spectral();
+
+    s.registerFormat('oas2', isOpenApiv2);
+    s.registerFormat('oas3', isOpenApiv3);
+    s.setFunctions({ oasDocumentSchema: setFunctionContext({ functions }, oasDocumentSchema) });
+    s.setRules({
+      'oas2-schema': {
+        ...rules['oas2-schema'],
+        type: RuleType[rules['oas2-schema'].type],
+        then: {
+          ...rules['oas2-schema'].then,
+          functionOptions: {
+            ...rules['oas2-schema'].then.functionOptions,
+            schema: oas2Schema,
+          },
+        },
+      },
+      'oas3-schema': {
+        ...rules['oas3-schema'],
+        type: RuleType[rules['oas3-schema'].type],
+        then: {
+          ...rules['oas3-schema'].then,
+          functionOptions: {
+            ...rules['oas3-schema'].then.functionOptions,
+            schema: oas3Schema,
+          },
+        },
+      },
+    });
+  });
+
+  describe('given OpenAPI 2 document', () => {
+    test('validate security definitions', async () => {
+      expect(
+        await s.run({
+          swagger: '2.0',
+          info: {
+            title: 'response example',
+            version: '1.0',
+          },
+          paths: {
+            '/user': {
+              get: {
+                responses: {
+                  200: {
+                    description: 'dummy description',
+                  },
+                },
+              },
+            },
+          },
+          securityDefinitions: {
+            basic: null,
+          },
+        }),
+      ).toEqual([
+        {
+          code: 'oas2-schema',
+          message: 'Invalid security definition.',
+          path: ['securityDefinitions', 'basic'],
+          severity: DiagnosticSeverity.Error,
+          range: expect.any(Object),
+        },
+      ]);
+    });
+  });
+
+  describe('given OpenAPI 3 document', () => {
+    test('validate parameters', async () => {
+      expect(
+        await s.run({
+          openapi: '3.0.1',
+          info: {
+            title: 'response example',
+            version: '1.0',
+          },
+          paths: {
+            '/user': {
+              get: {
+                responses: {
+                  200: {
+                    description: 'dummy description',
+                  },
+                },
+                parameters: [
+                  {
+                    name: 'module_id',
+                    in: 'bar',
+                    required: true,
+                    schema: {
+                      type: ['string', 'number'],
+                    },
+                  },
+                ],
+              },
+            },
+          },
+        }),
+      ).toEqual([
+        {
+          code: 'oas3-schema',
+          message: '`type` property type should be string.',
+          path: ['paths', '/user', 'get', 'parameters', '0', 'schema', 'type'],
+          severity: DiagnosticSeverity.Error,
+          range: expect.any(Object),
+        },
+      ]);
+    });
+
+    test('validate security schemes', async () => {
+      expect(
+        await s.run({
+          openapi: '3.0.1',
+          info: {
+            title: 'response example',
+            version: '1.0',
+          },
+          paths: {
+            '/user': {
+              get: {
+                responses: {
+                  200: {
+                    description: 'dummy description',
+                  },
+                },
+              },
+            },
+          },
+          components: {
+            securitySchemes: {
+              basic: {
+                foo: 2,
+              },
+            },
+          },
+        }),
+      ).toEqual([
+        {
+          code: 'oas3-schema',
+          message: 'Invalid security scheme.',
+          path: ['components', 'securitySchemes', 'basic'],
+          severity: DiagnosticSeverity.Error,
+          range: expect.any(Object),
+        },
+      ]);
+    });
+
+    test('validate responses', async () => {
+      expect(
+        await s.run({
+          openapi: '3.0.1',
+          info: {
+            title: 'response example',
+            version: '1.0',
+          },
+          paths: {
+            '/user': {
+              get: {
+                operationId: 'd',
+                responses: {
+                  200: {},
+                },
+              },
+            },
+          },
+        }),
+      ).toEqual([
+        {
+          code: 'oas3-schema',
+          message: '`200` property should have required property `description`.',
+          path: ['paths', '/user', 'get', 'responses', '200'],
+          severity: DiagnosticSeverity.Error,
+          range: expect.any(Object),
+        },
+      ]);
+    });
+  });
+
+  describe('prepareResults', () => {
+    test('given oneOf error one of which is required $ref property missing, picks only one error', () => {
+      const errors: ErrorObject[] = [
+        {
+          keyword: 'type',
+          dataPath: '/paths/test/post/parameters/0/schema/type',
+          schemaPath: '#/properties/type/type',
+          params: { type: 'string' },
+          message: 'should be string',
+        },
+        {
+          keyword: 'required',
+          dataPath: '/paths/test/post/parameters/0/schema',
+          schemaPath: '#/definitions/Reference/required',
+          params: { missingProperty: '$ref' },
+          message: "should have required property '$ref'",
+        },
+        {
+          keyword: 'oneOf',
+          dataPath: '/paths/test/post/parameters/0/schema',
+          schemaPath: '#/properties/schema/oneOf',
+          params: { passingSchemas: null },
+          message: 'should match exactly one schema in oneOf',
+        },
+      ];
+
+      prepareResults(errors);
+
+      expect(errors).toStrictEqual([
+        {
+          keyword: 'type',
+          dataPath: '/paths/test/post/parameters/0/schema/type',
+          schemaPath: '#/properties/type/type',
+          params: { type: 'string' },
+          message: 'should be string',
+        },
+      ]);
+    });
+
+    test('given oneOf error one without any $ref property missing, picks all errors', () => {
+      const errors: ErrorObject[] = [
+        {
+          keyword: 'type',
+          dataPath: '/paths/test/post/parameters/0/schema/type',
+          schemaPath: '#/properties/type/type',
+          params: { type: 'string' },
+          message: 'should be string',
+        },
+        {
+          keyword: 'type',
+          dataPath: '/paths/test/post/parameters/1/schema/type',
+          schemaPath: '#/properties/type/type',
+          params: { type: 'string' },
+          message: 'should be string',
+        },
+        {
+          keyword: 'oneOf',
+          dataPath: '/paths/test/post/parameters/0/schema',
+          schemaPath: '#/properties/schema/oneOf',
+          params: { passingSchemas: null },
+          message: 'should match exactly one schema in oneOf',
+        },
+      ];
+
+      prepareResults(errors);
+
+      expect(errors).toStrictEqual([
+        {
+          keyword: 'type',
+          dataPath: '/paths/test/post/parameters/0/schema/type',
+          schemaPath: '#/properties/type/type',
+          params: { type: 'string' },
+          message: 'should be string',
+        },
+        {
+          dataPath: '/paths/test/post/parameters/1/schema/type',
+          keyword: 'type',
+          message: 'should be string',
+          params: {
+            type: 'string',
+          },
+          schemaPath: '#/properties/type/type',
+        },
+        {
+          dataPath: '/paths/test/post/parameters/0/schema',
+          keyword: 'oneOf',
+          message: 'should match exactly one schema in oneOf',
+          params: {
+            passingSchemas: null,
+          },
+          schemaPath: '#/properties/schema/oneOf',
+        },
+      ]);
+    });
+
+    test('given errors with different data paths, picks all errors', () => {
+      const errors: ErrorObject[] = [
+        {
+          keyword: 'type',
+          dataPath: '/paths/test/post/parameters/0/schema/type',
+          schemaPath: '#/properties/type/type',
+          params: { type: 'string' },
+          message: 'should be string',
+        },
+        {
+          keyword: 'required',
+          dataPath: '/paths/foo/post/parameters/0/schema',
+          schemaPath: '#/definitions/Reference/required',
+          params: { missingProperty: '$ref' },
+          message: "should have required property '$ref'",
+        },
+        {
+          keyword: 'oneOf',
+          dataPath: '/paths/baz/post/parameters/0/schema',
+          schemaPath: '#/properties/schema/oneOf',
+          params: { passingSchemas: null },
+          message: 'should match exactly one schema in oneOf',
+        },
+      ];
+
+      prepareResults(errors);
+
+      expect(errors).toStrictEqual([
+        {
+          dataPath: '/paths/test/post/parameters/0/schema/type',
+          keyword: 'type',
+          message: 'should be string',
+          params: {
+            type: 'string',
+          },
+          schemaPath: '#/properties/type/type',
+        },
+        {
+          dataPath: '/paths/foo/post/parameters/0/schema',
+          keyword: 'required',
+          message: "should have required property '$ref'",
+          params: {
+            missingProperty: '$ref',
+          },
+          schemaPath: '#/definitions/Reference/required',
+        },
+        {
+          dataPath: '/paths/baz/post/parameters/0/schema',
+          keyword: 'oneOf',
+          message: 'should match exactly one schema in oneOf',
+          params: {
+            passingSchemas: null,
+          },
+          schemaPath: '#/properties/schema/oneOf',
+        },
+      ]);
+    });
+  });
+});
diff --git a/test-harness/scenarios/external-schemas-ruleset.scenario b/test-harness/scenarios/external-schemas-ruleset.scenario
index 3aa363c0..9b59fee9 100644
--- a/test-harness/scenarios/external-schemas-ruleset.scenario
+++ b/test-harness/scenarios/external-schemas-ruleset.scenario
@@ -11,7 +11,7 @@ info:
 OpenAPI 3.x detected
 
 {document}
- 3:10  error  info-title        string should be equal to one of the allowed values: Stoplight, Stoplight.io, StoplightIO. Did you mean Stoplight?
- 4:16  error  info-description  string should be equal to one of the allowed values: foo, foo-bar, bar-foo
+ 3:10  error  info-title        String should be equal to one of the allowed values: Stoplight, Stoplight.io, StoplightIO. Did you mean Stoplight?
+ 4:16  error  info-description  String should be equal to one of the allowed values: foo, foo-bar, bar-foo
 
 ✖ 2 problems (2 errors, 0 warnings, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/oas3-schema.scenario b/test-harness/scenarios/oas3-schema.scenario
new file mode 100644
index 00000000..317c0468
--- /dev/null
+++ b/test-harness/scenarios/oas3-schema.scenario
@@ -0,0 +1,63 @@
+====test====
+Prints reasonable error messages for oas3-schema errors.
+====document====
+openapi: 3.0.1
+info:
+  title: Example $ref error
+  version: 1.0.0
+  contact:
+    foo: me
+paths:
+  /user:
+    get:
+      operationId: getUser
+      parameters:
+        - type: string
+          name: module_id
+          in: path
+          required: true
+          schema:
+            type: string
+          description: Module ID
+        - name: module_id
+          in: bar
+          required: true
+          schema:
+            type: [string, number]
+      responses:
+        "200":
+          description: An Example
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  user_id: 12345
+            application/yaml:
+              schema:
+                type: object
+                properties:
+        "400":
+          description: []
+  /address:
+    get:
+      responses: {}
+====asset:ruleset====
+extends: [[spectral:oas, off]]
+rules:
+  oas3-schema: error
+====command====
+{bin} lint {document} --ruleset {asset:ruleset}
+====stdout====
+OpenAPI 3.x detected
+
+{document}
+  5:11  error  oas3-schema  Property `foo` is not expected to be here.
+ 12:11  error  oas3-schema  Property `type` is not expected to be here.
+ 23:18  error  oas3-schema  `type` property type should be string.
+ 32:28  error  oas3-schema  `user_id` property type should be object.
+ 36:28  error  oas3-schema  `properties` property type should be object.
+ 38:23  error  oas3-schema  `description` property type should be string.
+ 41:17  error  oas3-schema  `responses` property minProperties should NOT have fewer than 1 properties.
+
+✖ 7 problems (7 errors, 0 warnings, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/results-format-junit.oas3.scenario b/test-harness/scenarios/results-format-junit.oas3.scenario
index 94765faa..e0d3bf78 100644
--- a/test-harness/scenarios/results-format-junit.oas3.scenario
+++ b/test-harness/scenarios/results-format-junit.oas3.scenario
@@ -14,6 +14,6 @@ OpenAPI 3.x detected
 <?xml version="1.0" encoding="utf-8"?>
 <testsuites>
 <testsuite package="org.spectral" time="0" tests="1" errors="0" failures="1" name="{document}">
-<testcase time="0" name="org.spectral.oas3-schema" classname="{document|no-ext}"><failure message="property foo is not expected to be here"><![CDATA[line 2, col 6, property foo is not expected to be here (oas3-schema) at path #/info]]></failure></testcase>
+<testcase time="0" name="org.spectral.oas3-schema" classname="{document|no-ext}"><failure message="Property `foo` is not expected to be here."><![CDATA[line 2, col 6, Property `foo` is not expected to be here. (oas3-schema) at path #/info]]></failure></testcase>
 </testsuite>
 </testsuites>