Repository: Redocly/redocly-cli
Pull request #837 (2022-08-30 13:24:27)

feat: support for custom assertions functions
## What/Why/How?

Issue: #780. Issue: #855 

Now we can custom assertion in plugin.

_plugin.js_
```js
module.exports = {
  id: 'local',
  assertions: {
    checkWordsStarts: (value, opts, location) => {
      const regexp = new RegExp(`^${opts.words.join('|')}`);
      if (regexp.test(value)) {
        return [];
      }
      return [{ message: `${value} should start with one of ${opts.words.join(', ')}`, location }];
    },
    checkWordsCount: (value, opts, location) => {
      const words = value.split(' ');
      if (words.length >= opts.min) {
        return [];
      }
      return [{ message: `${value} should have at least ${opts.min} words`, location }];
    },
};
```
_.redocly.yaml_
```yaml
lint:
  rules:
    assert/operation-summary-length:
      subject: Operation
      property: summary
      local/checkWordsStarts: 
        words: 
          - Create
          - Retrieve
          - Merge
          - Delete
          - List
          - Upsert
          - Update
          - Approve
          - Reject
      local/checkWordsCount: 
        min: 3
```

## Reference

## Testing

## Screenshots (optional)

## Check yourself

- [x] Code is linted
- [ ] Tested with redoc/reference-docs/workflows
- [x] All new/updated code is covered with tests

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


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

## Coverage report 



| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category   | Percentage | Covered / Total |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------- | :--------- | :-------------: |
|                                                                           🟡                                                                           | Statements | 72.11%     |    3105/4306    |
|                                                                           🟡                                                                           | Branches   | 64.01%     |    1681/2626    |
|                                                                           🟡                                                                           | Functions  | 63.75%     |     503/789     |
|                                                                           🟡                                                                           | Lines      | 72.1%      |    2887/4004    |


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

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File                                                                               | Statements                                                     | Branches                                                       | Functions                                                      | Lines                                                          |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------- | :------------------------------------------------------------- | :------------------------------------------------------------- | :------------------------------------------------------------- | :------------------------------------------------------------- |
|                                                                           🟢                                                                           | <div title="core/src/rules/common/assertions/asserts.ts">`...` / asserts.ts</div>  | 78.75%                                                         | 71.64%                                                         | <div title="Base coverage is: 86.67%">81.25% (-5.42% 🔻)</div> | <div title="Base coverage is: 88.73%">87.5% (-1.23% 🔻)</div>  |
|                                                                           🟢                                                                           | <div title="core/src/config/config-resolvers.ts">`...` / config-resolvers.ts</div> | <div title="Base coverage is: 85.61%">81.56% (-4.05% 🔻)</div> | <div title="Base coverage is: 76.15%">74.53% (-1.62% 🔻)</div> | <div title="Base coverage is: 100%">94.12% (-5.88% 🔻)</div>   | <div title="Base coverage is: 85.94%">82.35% (-3.58% 🔻)</div> |

</details>

## Test suite run success
475 tests passing in 81 suites.

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

iosypov: @adamaltman , @RomanHotsiy please review syntax of custom function.
adamaltman: Great job. I think this is going to be a brilliant feature.
iosypov: Updated documentation. Need review @adamaltman, @RomanHotsiy 

Patch:
diff --git a/docs/rules/assertions.md b/docs/rules/assertions.md
index dc723170..902b2b8d 100644
--- a/docs/rules/assertions.md
+++ b/docs/rules/assertions.md
@@ -30,7 +30,7 @@ Property | Type | Description
 subject | `string` \| [`string`] | **REQUIRED.** The [OpenAPI node type](#openapi-node-types) that the lint evaluates. Use with `context` for more control.
 property | `string` \| [`string`] \| null | Property name corresponding to the [OpenAPI node type](#openapi-node-types). If a list of properties is provided, assertions will evaluate against each property in the sequence. If not provided (or null), assertions will evaluate against the key names for the subject node type. See [property example](#property-example).
 context | [Context object](#context-object) | The context influences evaluation for assertions. When `matchParentKeys` or `excludeParentKeys` is used in the `context` object, it evaluates the specified subset of the subject type. The resolution of reference objects is done at the context level. If no context is provided, it evaluates the assertion for all instances of the given type. See [context example](#context-example).
-message | `string` | Problem message displayed if the assertion is false. If omitted, the default message "The {assertion name} doesn't meet required conditions" is displayed.
+message | `string` | Problem message displayed if the assertion is false. If omitted, the default message is: "{{assertionName}} failed because the {{subject}} {{property}} didn't meet the assertions: {{problems}}" is displayed. The available placeholders are displayed in that message. In the case there are multiple properties, the `{{property}}` placeholder produces a comma and space separate list of properties. In case there are multiple problems, the `{{problems}}` placeholder produces a bullet-list with a new line between each problem.
 suggest | [`string`] | List of suggestions to display if the problem occurs.
 severity | `string` | Configure the severity level of the problem if the assertion is false. It must be one of these values: `error`, `warn`, `off`. Default value is `error`.
 enum | [`string`] | Asserts a value is within a predefined list of values. See [enum example](#enum-example).
@@ -46,7 +46,8 @@ undefined | `boolean` | Asserts a property is undefined. See [undefined example]
 nonEmpty | `boolean` | Asserts a property is not empty. See [nonEmpty example](#nonempty-example).
 minLength | `integer` | Asserts a minimum length (inclusive) of a string or list (array). See [minLength example](#minlength-example).
 maxLength | `integer` | Asserts a maximum length (exclusive) of a string or list (array). See [maxLength example](#maxlength-example).
-ref | `boolean | string` | Asserts a reference object presence in object's property. A boolean value of `true` means the property has a `$ref` defined. A boolean value of `false` means the property has not defined a `$ref` (it has an in-place value). A string value means that the `$ref` is defined and the unresolved value must match the pattern (for example, `'/paths\/.*\.yaml$/'`). See [ref example](#ref-example).|
+ref | `boolean \| string` | Asserts a reference object presence in object's property. A boolean value of `true` means the property has a `$ref` defined. A boolean value of `false` means the property has not defined a `$ref` (it has an in-place value). A string value means that the `$ref` is defined and the unresolved value must match the pattern (for example, `'/paths\/.*\.yaml$/'`). See [ref example](#ref-example).|
+`{pluginId}/{functionName}` | `object` | Custom assert defined in the plugin. This function will be called with options including the value. See [custom function example](#custom-function-example).|
 
 ## Context object
 
@@ -180,6 +181,75 @@ assert/no-pdf-in-ok-response:
   disallowed: ['application/pdf']
 ```
 
+### Custom function example
+
+The following example asserts that `Operation` summary should start with an active verb and have at least three words.
+
+The configuration file uses two custom functions `local/checkWordsStarts` and `local/checkWordsCount`. `local/checkWordsStarts` has a list of `words` in the options. `local/checkWordsCount` has options with `min` which means that summary field should have a minimum number of words.
+
+In `plugin.js` each functions retrieves its options, checks for problems, and returns a list of problems.
+
+Each function is called with the following parameters:
+
+Property | Type | Description
+-- | -- | --
+value | `string` \| [`string`] | Value that appears at the corresponding location.
+options | `object` | Options that is described in config file.
+location | `Location Object` | Location in the source document. See [Location Object](../resources/custom-rules.md#location-object)
+**Return**
+problems | [`Problem`] | List of problems. Empty list indicates that all checks are valid.
+
+`Problem`
+Property | Type | Description
+-- | -- | --
+message | `string` \| [`string`] | Problem message that will be displayed in result
+location | `Location Object` | Location in the source document. See [Location Object](../resources/custom-rules.md#location-object)
+
+`.redocly.yaml`
+```yaml
+assert/operation-summary-check:
+  subject: Operation
+  property: summary
+  message: Operation summary should start with an active verb
+    local/checkWordsStarts: 
+      words: 
+        - Create
+        - Retrieve
+        - Merge
+        - Delete
+        - List
+        - Upsert
+        - Update
+        - Approve
+        - Reject
+    local/checkWordsCount: 
+      min: 3
+```
+`plugin.js`
+```js
+module.exports = {
+  id: 'local',
+  assertions: {
+    checkWordsStarts: (value, options, location) => {
+      const regexp = new RegExp(`^${options.words.join('|')}`);
+      if (regexp.test(value)) {
+        return [];
+      }
+      return [{ message: 'Operation summary should start with an active verb', location }];
+    },
+    checkWordsCount: (value, options, location) => {
+      const words = value.split(' ');
+      if (words.length >= options.min) {
+        return [];
+      }
+      return [
+        { message: `Operation summary should contain at least ${options.min} words`, location },
+      ];
+    },
+  },
+};
+```
+
 ### `enum` example
 
 The following example asserts that only `application/json` can be used as a key of the MediaTypesMap.
diff --git a/packages/core/src/config/config-resolvers.ts b/packages/core/src/config/config-resolvers.ts
index 3d29cfdd..a2440c16 100644
--- a/packages/core/src/config/config-resolvers.ts
+++ b/packages/core/src/config/config-resolvers.ts
@@ -24,6 +24,7 @@ import { isBrowser } from '../env';
 import { isNotString, isString, isDefined, parseYaml } from '../utils';
 import { Config } from './config';
 import { colorize, logger } from '../logger';
+import { asserts, buildAssertCustomFunction } from '../rules/common/assertions/asserts';
 
 export async function resolveConfig(rawConfig: RawConfig, configPath?: string): Promise<Config> {
   if (rawConfig.styleguide?.extends?.some(isNotString)) {
@@ -175,6 +176,10 @@ export function resolvePlugins(
         }
       }
 
+      if (pluginModule.assertions) {
+        plugin.assertions = pluginModule.assertions;
+      }
+
       return plugin;
     })
     .filter(isDefined);
@@ -299,8 +304,7 @@ export async function resolveStyleguideConfig(
   return {
     ...resolvedStyleguideConfig,
     rules:
-      resolvedStyleguideConfig.rules &&
-      groupStyleguideAssertionRules(resolvedStyleguideConfig.rules),
+      resolvedStyleguideConfig.rules && groupStyleguideAssertionRules(resolvedStyleguideConfig),
   };
 }
 
@@ -392,9 +396,10 @@ function getMergedRawStyleguideConfig(
   return resultLint;
 }
 
-function groupStyleguideAssertionRules(
-  rules: Record<string, RuleConfig> | undefined
-): Record<string, RuleConfig> | undefined {
+function groupStyleguideAssertionRules({
+  rules,
+  plugins,
+}: ResolvedStyleguideConfig): Record<string, RuleConfig> | undefined {
   if (!rules) {
     return rules;
   }
@@ -407,6 +412,24 @@ function groupStyleguideAssertionRules(
   for (const [ruleKey, rule] of Object.entries(rules)) {
     if (ruleKey.startsWith('assert/') && typeof rule === 'object' && rule !== null) {
       const assertion = rule;
+      if (plugins) {
+        for (const field of Object.keys(assertion)) {
+          const [pluginId, fn] = field.split('/');
+          if (!pluginId || !fn) continue;
+          const plugin = plugins.find((plugin) => plugin.id === pluginId);
+          if (!plugin) {
+            throw Error(colorize.red(`Plugin ${colorize.blue(pluginId)} isn't found.`));
+          }
+          if (!plugin.assertions || !plugin.assertions[fn]) {
+            throw Error(
+              `Plugin ${colorize.red(
+                pluginId
+              )} doesn't export assertions function with name ${colorize.red(fn)}.`
+            );
+          }
+          asserts[field] = buildAssertCustomFunction(plugin.assertions[fn]);
+        }
+      }
       assertions.push({
         ...assertion,
         assertionId: ruleKey.replace('assert/', ''),
diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts
index bed8ca75..3ed93c6e 100644
--- a/packages/core/src/config/types.ts
+++ b/packages/core/src/config/types.ts
@@ -10,6 +10,7 @@ import type {
   OasVersion,
 } from '../oas-types';
 import type { NodeType } from '../types';
+import { Location } from '../ref-utils';
 
 export type RuleSeverity = ProblemSeverity | 'off';
 
@@ -83,6 +84,15 @@ export type CustomRulesConfig = {
   oas2?: Oas2RuleSet;
 };
 
+export type AssertResult = { message?: string; location?: Location };
+export type CustomFunction = (
+  value: any,
+  options: unknown,
+  baseLocation: Location
+) => AssertResult[];
+
+export type AssertionsConfig = Record<string, CustomFunction>;
+
 export type Plugin = {
   id: string;
   configs?: Record<string, PluginStyleguideConfig>;
@@ -90,6 +100,7 @@ export type Plugin = {
   preprocessors?: PreprocessorsConfig;
   decorators?: DecoratorsConfig;
   typeExtension?: TypeExtensionsConfig;
+  assertions?: AssertionsConfig;
 };
 
 export type PluginStyleguideConfig = Omit<StyleguideRawConfig, 'plugins' | 'extends'>;
diff --git a/packages/core/src/rules/common/assertions/asserts.ts b/packages/core/src/rules/common/assertions/asserts.ts
index 4e176ab3..b45578fe 100644
--- a/packages/core/src/rules/common/assertions/asserts.ts
+++ b/packages/core/src/rules/common/assertions/asserts.ts
@@ -1,5 +1,6 @@
+import { AssertResult, CustomFunction } from 'core/src/config/types';
 import { Location } from '../../../ref-utils';
-import { isString as runOnValue } from '../../../utils';
+import { isString as runOnValue, isTruthy } from '../../../utils';
 import {
   OrderOptions,
   OrderDirection,
@@ -8,10 +9,9 @@ import {
   regexFromString,
 } from './utils';
 
-type AssertResult = { isValid: boolean; location?: Location };
 type Asserts = Record<
   string,
-  (value: any, condition: any, baseLocation: Location, rawValue?: any) => AssertResult
+  (value: any, condition: any, baseLocation: Location, rawValue?: any) => AssertResult[]
 >;
 
 export const runOnKeysSet = new Set([
@@ -43,57 +43,80 @@ export const runOnValuesSet = new Set([
 
 export const asserts: Asserts = {
   pattern: (value: string | string[], condition: string, baseLocation: Location) => {
-    if (typeof value === 'undefined') return { isValid: true }; // property doesn't exist, no need to lint it with this assert
+    if (typeof value === 'undefined') return []; // property doesn't exist, no need to lint it with this assert
     const values = runOnValue(value) ? [value] : value;
     const regx = regexFromString(condition);
-    for (const _val of values) {
-      if (!regx?.test(_val)) {
-        return { isValid: false, location: runOnValue(value) ? baseLocation : baseLocation.key() };
-      }
-    }
-    return { isValid: true };
+
+    return values
+      .map(
+        (_val) =>
+          !regx?.test(_val) && {
+            message: `"${_val}" should match a regex ${condition}`,
+            location: runOnValue(value) ? baseLocation : baseLocation.key(),
+          }
+      )
+      .filter(isTruthy);
   },
   enum: (value: string | string[], condition: string[], baseLocation: Location) => {
-    if (typeof value === 'undefined') return { isValid: true }; // property doesn't exist, no need to lint it with this assert
+    if (typeof value === 'undefined') return []; // property doesn't exist, no need to lint it with this assert
     const values = runOnValue(value) ? [value] : value;
-    for (const _val of values) {
-      if (!condition.includes(_val)) {
-        return {
-          isValid: false,
-          location: runOnValue(value) ? baseLocation : baseLocation.child(_val).key(),
-        };
-      }
-    }
-    return { isValid: true };
+    return values
+      .map(
+        (_val) =>
+          !condition.includes(_val) && {
+            message: `"${_val}" should be one of the predefined values`,
+            location: runOnValue(value) ? baseLocation : baseLocation.child(_val).key(),
+          }
+      )
+      .filter(isTruthy);
   },
   defined: (value: string | undefined, condition: boolean = true, baseLocation: Location) => {
     const isDefined = typeof value !== 'undefined';
-    return { isValid: condition ? isDefined : !isDefined, location: baseLocation };
+    const isValid = condition ? isDefined : !isDefined;
+    return isValid
+      ? []
+      : [
+          {
+            message: condition ? `Should be defined` : 'Should be not defined',
+            location: baseLocation,
+          },
+        ];
   },
   required: (value: string[], keys: string[], baseLocation: Location) => {
-    for (const requiredKey of keys) {
-      if (!value.includes(requiredKey)) {
-        return { isValid: false, location: baseLocation.key() };
-      }
-    }
-    return { isValid: true };
+    return keys
+      .map(
+        (requiredKey) =>
+          !value.includes(requiredKey) && {
+            message: `${requiredKey} is required`,
+            location: baseLocation.key(),
+          }
+      )
+      .filter(isTruthy);
   },
   disallowed: (value: string | string[], condition: string[], baseLocation: Location) => {
-    if (typeof value === 'undefined') return { isValid: true }; // property doesn't exist, no need to lint it with this assert
+    if (typeof value === 'undefined') return []; // property doesn't exist, no need to lint it with this assert
     const values = runOnValue(value) ? [value] : value;
-    for (const _val of values) {
-      if (condition.includes(_val)) {
-        return {
-          isValid: false,
-          location: runOnValue(value) ? baseLocation : baseLocation.child(_val).key(),
-        };
-      }
-    }
-    return { isValid: true };
+    return values
+      .map(
+        (_val) =>
+          condition.includes(_val) && {
+            message: `"${_val}" is disallowed`,
+            location: runOnValue(value) ? baseLocation : baseLocation.child(_val).key(),
+          }
+      )
+      .filter(isTruthy);
   },
   undefined: (value: any, condition: boolean = true, baseLocation: Location) => {
     const isUndefined = typeof value === 'undefined';
-    return { isValid: condition ? isUndefined : !isUndefined, location: baseLocation };
+    const isValid = condition ? isUndefined : !isUndefined;
+    return isValid
+      ? []
+      : [
+          {
+            message: condition ? `Should not be defined` : 'Should be defined',
+            location: baseLocation,
+          },
+        ];
   },
   nonEmpty: (
     value: string | undefined | null,
@@ -101,85 +124,120 @@ export const asserts: Asserts = {
     baseLocation: Location
   ) => {
     const isEmpty = typeof value === 'undefined' || value === null || value === '';
-    return { isValid: condition ? !isEmpty : isEmpty, location: baseLocation };
+    const isValid = condition ? !isEmpty : isEmpty;
+    return isValid
+      ? []
+      : [
+          {
+            message: condition ? `Should not be empty` : 'Should be empty',
+            location: baseLocation,
+          },
+        ];
   },
   minLength: (value: string | any[], condition: number, baseLocation: Location) => {
-    if (typeof value === 'undefined') return { isValid: true }; // property doesn't exist, no need to lint it with this assert
-    return { isValid: value.length >= condition, location: baseLocation };
+    if (typeof value === 'undefined' || value.length >= condition) return []; // property doesn't exist, no need to lint it with this assert
+    return [{ message: `Should have at least ${condition} characters`, location: baseLocation }];
   },
   maxLength: (value: string | any[], condition: number, baseLocation: Location) => {
-    if (typeof value === 'undefined') return { isValid: true }; // property doesn't exist, no need to lint it with this assert
-    return { isValid: value.length <= condition, location: baseLocation };
+    if (typeof value === 'undefined' || value.length <= condition) return []; // property doesn't exist, no need to lint it with this assert
+    return [{ message: `Should have at most ${condition} characters`, location: baseLocation }];
   },
   casing: (value: string | string[], condition: string, baseLocation: Location) => {
-    if (typeof value === 'undefined') return { isValid: true }; // property doesn't exist, no need to lint it with this assert
+    if (typeof value === 'undefined') return []; // property doesn't exist, no need to lint it with this assert
     const values: string[] = runOnValue(value) ? [value] : value;
-    for (const _val of values) {
-      let matchCase = false;
-      switch (condition) {
-        case 'camelCase':
-          matchCase = !!_val.match(/^[a-z][a-zA-Z0-9]+$/g);
-          break;
-        case 'kebab-case':
-          matchCase = !!_val.match(/^([a-z][a-z0-9]*)(-[a-z0-9]+)*$/g);
-          break;
-        case 'snake_case':
-          matchCase = !!_val.match(/^([a-z][a-z0-9]*)(_[a-z0-9]+)*$/g);
-          break;
-        case 'PascalCase':
-          matchCase = !!_val.match(/^[A-Z][a-zA-Z0-9]+$/g);
-          break;
-        case 'MACRO_CASE':
-          matchCase = !!_val.match(/^([A-Z][A-Z0-9]*)(_[A-Z0-9]+)*$/g);
-          break;
-        case 'COBOL-CASE':
-          matchCase = !!_val.match(/^([A-Z][A-Z0-9]*)(-[A-Z0-9]+)*$/g);
-          break;
-        case 'flatcase':
-          matchCase = !!_val.match(/^[a-z][a-z0-9]+$/g);
-          break;
-      }
-      if (!matchCase) {
-        return {
-          isValid: false,
-          location: runOnValue(value) ? baseLocation : baseLocation.child(_val).key(),
-        };
-      }
-    }
-    return { isValid: true };
+    const casingRegexes: Record<string, RegExp> = {
+      camelCase: /^[a-z][a-zA-Z0-9]+$/g,
+      'kebab-case': /^([a-z][a-z0-9]*)(-[a-z0-9]+)*$/g,
+      snake_case: /^([a-z][a-z0-9]*)(_[a-z0-9]+)*$/g,
+      PascalCase: /^[A-Z][a-zA-Z0-9]+$/g,
+      MACRO_CASE: /^([A-Z][A-Z0-9]*)(_[A-Z0-9]+)*$/g,
+      'COBOL-CASE': /^([A-Z][A-Z0-9]*)(-[A-Z0-9]+)*$/g,
+      flatcase: /^[a-z][a-z0-9]+$/g,
+    };
+    return values
+      .map(
+        (_val) =>
+          !_val.match(casingRegexes[condition]) && {
+            message: `"${_val}" should use ${condition}`,
+            location: runOnValue(value) ? baseLocation : baseLocation.child(_val).key(),
+          }
+      )
+      .filter(isTruthy);
   },
   sortOrder: (value: any[], condition: OrderOptions | OrderDirection, baseLocation: Location) => {
-    if (typeof value === 'undefined') return { isValid: true };
-    return { isValid: isOrdered(value, condition), location: baseLocation };
+    if (typeof value === 'undefined' || isOrdered(value, condition)) return [];
+    const direction = (condition as OrderOptions).direction || (condition as OrderDirection);
+    const property = (condition as OrderOptions).property;
+    return [
+      {
+        message: `Should be sorted in ${
+          direction === 'asc' ? 'an ascending' : 'a descending'
+        } order${property ? ` by property ${property}` : ''}`,
+        location: baseLocation,
+      },
+    ];
   },
   mutuallyExclusive: (value: string[], condition: string[], baseLocation: Location) => {
-    return { isValid: getIntersectionLength(value, condition) < 2, location: baseLocation.key() };
+    if (getIntersectionLength(value, condition) < 2) return [];
+    return [
+      {
+        message: `${condition.join(', ')} keys should be mutually exclusive`,
+        location: baseLocation.key(),
+      },
+    ];
   },
   mutuallyRequired: (value: string[], condition: string[], baseLocation: Location) => {
-    return {
-      isValid:
-        getIntersectionLength(value, condition) > 0
-          ? getIntersectionLength(value, condition) === condition.length
-          : true,
-      location: baseLocation.key(),
-    };
+    const isValid =
+      getIntersectionLength(value, condition) > 0
+        ? getIntersectionLength(value, condition) === condition.length
+        : true;
+    return isValid
+      ? []
+      : [
+          {
+            message: `Properties ${condition.join(', ')} are mutually required`,
+            location: baseLocation.key(),
+          },
+        ];
   },
   requireAny: (value: string[], condition: string[], baseLocation: Location) => {
-    return { isValid: getIntersectionLength(value, condition) >= 1, location: baseLocation.key() };
+    return getIntersectionLength(value, condition) >= 1
+      ? []
+      : [
+          {
+            message: `Should have any of ${condition.join(', ')}`,
+            location: baseLocation.key(),
+          },
+        ];
   },
   ref: (_value: any, condition: string | boolean, baseLocation, rawValue: any) => {
-    if (typeof rawValue === 'undefined') return { isValid: true }; // property doesn't exist, no need to lint it with this assert
+    if (typeof rawValue === 'undefined') return []; // property doesn't exist, no need to lint it with this assert
     const hasRef = rawValue.hasOwnProperty('$ref');
     if (typeof condition === 'boolean') {
-      return {
-        isValid: condition ? hasRef : !hasRef,
-        location: hasRef ? baseLocation : baseLocation.key(),
-      };
+      const isValid = condition ? hasRef : !hasRef;
+      return isValid
+        ? []
+        : [
+            {
+              message: condition ? `should use $ref` : 'should not use $ref',
+              location: hasRef ? baseLocation : baseLocation.key(),
+            },
+          ];
     }
     const regex = regexFromString(condition);
-    return {
-      isValid: hasRef && regex?.test(rawValue['$ref']),
-      location: hasRef ? baseLocation : baseLocation.key(),
-    };
+    const isValid = hasRef && regex?.test(rawValue['$ref']);
+    return isValid
+      ? []
+      : [
+          {
+            message: `$ref value should match ${condition}`,
+            location: hasRef ? baseLocation : baseLocation.key(),
+          },
+        ];
   },
 };
+
+export function buildAssertCustomFunction(fn: CustomFunction) {
+  return (value: string[], options: any, baseLocation: Location) =>
+    fn.call(null, value, options, baseLocation);
+}
diff --git a/packages/core/src/rules/common/assertions/index.ts b/packages/core/src/rules/common/assertions/index.ts
index 0143b3cc..9d082a92 100644
--- a/packages/core/src/rules/common/assertions/index.ts
+++ b/packages/core/src/rules/common/assertions/index.ts
@@ -17,7 +17,6 @@ export const Assertions: Oas3Rule | Oas2Rule = (opts: object) => {
   for (const [index, assertion] of assertions.entries()) {
     const assertId =
       (assertion.assertionId && `${assertion.assertionId} assertion`) || `assertion #${index + 1}`;
-
     if (!assertion.subject) {
       throw new Error(`${assertId}: 'subject' is required`);
     }
@@ -30,12 +29,8 @@ export const Assertions: Oas3Rule | Oas2Rule = (opts: object) => {
       .filter((assertName: string) => assertion[assertName] !== undefined)
       .map((assertName: string) => {
         return {
-          assertId,
           name: assertName,
           conditions: assertion[assertName],
-          message: assertion.message,
-          severity: assertion.severity || 'error',
-          suggest: assertion.suggest || [],
           runsOnKeys: runOnKeysSet.has(assertName),
           runsOnValues: runOnValuesSet.has(assertName),
         };
@@ -61,11 +56,7 @@ export const Assertions: Oas3Rule | Oas2Rule = (opts: object) => {
     }
 
     for (const subject of subjects) {
-      const subjectVisitor = buildSubjectVisitor(
-        assertion.property,
-        assertsToApply,
-        assertion.context
-      );
+      const subjectVisitor = buildSubjectVisitor(assertId, assertion, assertsToApply);
       const visitorObject = buildVisitorObject(subject, assertion.context, subjectVisitor);
       visitors.push(visitorObject);
     }
diff --git a/packages/core/src/rules/common/assertions/utils.ts b/packages/core/src/rules/common/assertions/utils.ts
index 03e89c49..a000ed2e 100644
--- a/packages/core/src/rules/common/assertions/utils.ts
+++ b/packages/core/src/rules/common/assertions/utils.ts
@@ -1,5 +1,7 @@
+import type { AssertResult, RuleSeverity } from '../../../config';
+import { colorize } from '../../../logger';
 import { isRef, Location } from '../../../ref-utils';
-import { Problem, ProblemSeverity, UserContext } from '../../../walk';
+import { UserContext } from '../../../walk';
 import { asserts } from './asserts';
 
 export type OrderDirection = 'asc' | 'desc';
@@ -9,13 +11,18 @@ export type OrderOptions = {
   property: string;
 };
 
+type Assertion = {
+  property: string | string[];
+  context?: Record<string, any>[];
+  severity?: RuleSeverity;
+  suggest?: any[];
+  message?: string;
+  subject: string;
+};
+
 export type AssertToApply = {
   name: string;
-  assertId?: string;
   conditions: any;
-  message?: string;
-  severity?: ProblemSeverity;
-  suggest?: string[];
   runsOnKeys: boolean;
   runsOnValues: boolean;
 };
@@ -73,19 +80,20 @@ export function buildVisitorObject(
 }
 
 export function buildSubjectVisitor(
-  properties: string | string[],
-  asserts: AssertToApply[],
-  context?: Record<string, any>[]
+  assertId: string,
+  assertion: Assertion,
+  asserts: AssertToApply[]
 ) {
   return (
     node: any,
     { report, location, rawLocation, key, type, resolve, rawNode }: UserContext
   ) => {
+    let properties = assertion.property;
     // We need to check context's last node if it has the same type as subject node;
     // if yes - that means we didn't create context's last node visitor,
     // so we need to handle 'matchParentKeys' and 'excludeParentKeys' conditions here;
-    if (context) {
-      const lastContextNode = context[context.length - 1];
+    if (assertion.context) {
+      const lastContextNode = assertion.context[assertion.context.length - 1];
       if (lastContextNode.type === type.name) {
         const matchParentKeys = lastContextNode.matchParentKeys;
         const excludeParentKeys = lastContextNode.excludeParentKeys;
@@ -103,34 +111,66 @@ export function buildSubjectVisitor(
       properties = Array.isArray(properties) ? properties : [properties];
     }
 
+    const defaultMessage = `${colorize.blue(assertId)} failed because the ${colorize.blue(
+      assertion.subject
+    )}${colorize.blue(
+      properties ? ` ${(properties as string[]).join(', ')}` : ''
+    )} didn't meet the assertions: {{problems}}`;
+
+    const assertResults: Array<AssertResult[]> = [];
     for (const assert of asserts) {
       const currentLocation = assert.name === 'ref' ? rawLocation : location;
       if (properties) {
         for (const property of properties) {
           // we can have resolvable scalar so need to resolve value here.
           const value = isRef(node[property]) ? resolve(node[property])?.node : node[property];
-          runAssertion({
-            values: value,
-            rawValues: rawNode[property],
-            assert,
-            location: currentLocation.child(property),
-            report,
-          });
+          assertResults.push(
+            runAssertion({
+              values: value,
+              rawValues: rawNode[property],
+              assert,
+              location: currentLocation.child(property),
+            })
+          );
         }
       } else {
         const value = assert.name === 'ref' ? rawNode : Object.keys(node);
-        runAssertion({
-          values: Object.keys(node),
-          rawValues: value,
-          assert,
-          location: currentLocation,
-          report,
-        });
+        assertResults.push(
+          runAssertion({
+            values: Object.keys(node),
+            rawValues: value,
+            assert,
+            location: currentLocation,
+          })
+        );
       }
     }
+
+    const problems = assertResults.flat();
+    if (problems.length) {
+      const message = assertion.message || defaultMessage;
+
+      report({
+        message: message.replace('{{problems}}', getProblemsMessage(problems)),
+        location: getProblemsLocation(problems) || location,
+        forceSeverity: assertion.severity || 'error',
+        suggest: assertion.suggest || [],
+        ruleId: assertId,
+      });
+    }
   };
 }
 
+function getProblemsLocation(problems: AssertResult[]) {
+  return problems.length ? problems[0].location : undefined;
+}
+
+function getProblemsMessage(problems: AssertResult[]) {
+  return problems.length === 1
+    ? problems[0].message ?? ''
+    : problems.map((problem) => `\n- ${problem.message ?? ''}`).join('');
+}
+
 export function getIntersectionLength(keys: string[], properties: string[]): number {
   const props = new Set(properties);
   let count = 0;
@@ -170,20 +210,10 @@ type RunAssertionParams = {
   rawValues: any;
   assert: AssertToApply;
   location: Location;
-  report: (problem: Problem) => void;
 };
 
-function runAssertion({ values, rawValues, assert, location, report }: RunAssertionParams) {
-  const lintResult = asserts[assert.name](values, assert.conditions, location, rawValues);
-  if (!lintResult.isValid) {
-    report({
-      message: assert.message || `The ${assert.assertId} doesn't meet required conditions`,
-      location: lintResult.location || location,
-      forceSeverity: assert.severity,
-      suggest: assert.suggest,
-      ruleId: assert.assertId,
-    });
-  }
+function runAssertion({ values, rawValues, assert, location }: RunAssertionParams): AssertResult[] {
+  return asserts[assert.name](values, assert.conditions, location, rawValues);
 }
 
 export function regexFromString(input: string): RegExp | null {
diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts
index a868c0c5..0cd0d537 100644
--- a/packages/core/src/types/redocly-yaml.ts
+++ b/packages/core/src/types/redocly-yaml.ts
@@ -287,6 +287,10 @@ const Assert: NodeType = {
     ref: (value: string | boolean) =>
       typeof value === 'string' ? { type: 'string' } : { type: 'boolean' },
   },
+  additionalProperties: (_value: unknown, key: string) => {
+    if (/^\w+\/\w+$/.test(key)) return { type: 'object' };
+    return;
+  },
   required: ['subject'],
 };
 


Test patch:
diff --git a/__tests__/lint/assertions-custom-function-options/.redocly.yaml b/__tests__/lint/assertions-custom-function-options/.redocly.yaml
new file mode 100644
index 00000000..cf7e8b69
--- /dev/null
+++ b/__tests__/lint/assertions-custom-function-options/.redocly.yaml
@@ -0,0 +1,42 @@
+apis:
+  main:
+    root: ./openapi.yaml
+plugins:
+  - ./plugin.js
+rules:
+  assert/operation-summary-length-custom:
+    subject: Operation
+    property: summary
+    message: Custom message {{problems}}
+    minLength: 16
+    local/checkWordsStarts: 
+      words: 
+        - Create
+        - Retrieve
+        - Merge
+        - Delete
+        - List
+        - Upsert
+        - Update
+        - Approve
+        - Reject
+    local/checkWordsCount: 
+      min: 3
+  assert/operation-summary-length:
+    subject: Operation
+    property: summary
+    minLength: 16
+    local/checkWordsStarts: 
+      words: 
+        - Create
+        - Retrieve
+        - Merge
+        - Delete
+        - List
+        - Upsert
+        - Update
+        - Approve
+        - Reject
+    local/checkWordsCount: 
+      min: 3
+  extends: []
diff --git a/__tests__/lint/assertions-custom-function-options/openapi.yaml b/__tests__/lint/assertions-custom-function-options/openapi.yaml
new file mode 100644
index 00000000..4dfb610b
--- /dev/null
+++ b/__tests__/lint/assertions-custom-function-options/openapi.yaml
@@ -0,0 +1,49 @@
+openapi: 3.1.0
+servers:
+  - url: http://example.xyz
+info:
+  license:
+    name: test licence
+    url: http://example.xyz
+  title: Example OpenAPI 3 definition.
+  description: Example description
+  version: '1.0'
+  contact:
+    name: qa
+    url: https://swagger.io/specification/#definitions
+    email: email@redoc.ly
+
+paths:
+  /pet/findByStatus:
+    get:
+      operationId: example
+      summary: List of pets
+      tags:
+        - foo
+        - baz
+        - bar
+      responses:
+        '200':
+          description: example description
+          content:
+            application/json:
+              schema:
+                type: 'string'
+        '404':
+          description: example description
+    post:
+      operationId: example-post
+      summary: summary looooong
+      tags:
+        - foo
+        - baz
+        - bar
+      responses:
+        '200':
+          description: example description
+          content:
+            application/json:
+              schema:
+                type: 'string'
+        '404':
+          description: example description
diff --git a/__tests__/lint/assertions-custom-function-options/plugin.js b/__tests__/lint/assertions-custom-function-options/plugin.js
new file mode 100644
index 00000000..8a2530c4
--- /dev/null
+++ b/__tests__/lint/assertions-custom-function-options/plugin.js
@@ -0,0 +1,19 @@
+module.exports = {
+  id: 'local',
+  assertions: {
+    checkWordsStarts: (value, opts, location) => {
+      const regexp = new RegExp(`^${opts.words.join('|')}`);
+      if (regexp.test(value)) {
+        return [];
+      }
+      return [{ message: `Should start with one of ${opts.words.join(', ')}`, location }];
+    },
+    checkWordsCount: (value, opts, location) => {
+      const words = value.split(' ');
+      if (words.length >= opts.min) {
+        return [];
+      }
+      return [{ message: `Should have at least ${opts.min} words`, location }];
+    },
+  },
+};
diff --git a/__tests__/lint/assertions-custom-function-options/snapshot.js b/__tests__/lint/assertions-custom-function-options/snapshot.js
new file mode 100644
index 00000000..5ba63d60
--- /dev/null
+++ b/__tests__/lint/assertions-custom-function-options/snapshot.js
@@ -0,0 +1,105 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`E2E lint assertions-custom-function-options 1`] = `
+
+[1] .redocly.yaml:43:5 at #/styleguide/rules/extends
+
+Property \`extends\` is not expected here.
+
+41 |   local/checkWordsCount:
+42 |     min: 3
+43 | extends: []
+   | ^^^^^^^
+44 |
+
+Warning was generated by the configuration spec rule.
+
+
+You have 1 warning.
+No configurations were defined in extends -- using built in recommended configuration by default.
+Warning! This default behavior is going to be deprecated soon.
+
+validating /openapi.yaml...
+[1] openapi.yaml:20:16 at #/paths/~1pet~1findByStatus/get/summary
+
+Custom message Should have at least 16 characters
+
+18 | get:
+19 |   operationId: example
+20 |   summary: List of pets
+   |            ^^^^^^^^^^^^
+21 |   tags:
+22 |     - foo
+
+Error was generated by the operation-summary-length-custom assertion rule.
+
+
+[2] openapi.yaml:20:16 at #/paths/~1pet~1findByStatus/get/summary
+
+operation-summary-length assertion failed because the Operation summary didn't meet the assertions: Should have at least 16 characters
+
+18 | get:
+19 |   operationId: example
+20 |   summary: List of pets
+   |            ^^^^^^^^^^^^
+21 |   tags:
+22 |     - foo
+
+Error was generated by the operation-summary-length assertion rule.
+
+
+[3] openapi.yaml:36:16 at #/paths/~1pet~1findByStatus/post/summary
+
+Custom message 
+- Should start with one of Create, Retrieve, Merge, Delete, List, Upsert, Update, Approve, Reject
+- Should have at least 3 words
+
+34 | post:
+35 |   operationId: example-post
+36 |   summary: summary looooong
+   |            ^^^^^^^^^^^^^^^^
+37 |   tags:
+38 |     - foo
+
+Error was generated by the operation-summary-length-custom assertion rule.
+
+
+[4] openapi.yaml:36:16 at #/paths/~1pet~1findByStatus/post/summary
+
+operation-summary-length assertion failed because the Operation summary didn't meet the assertions: 
+- Should start with one of Create, Retrieve, Merge, Delete, List, Upsert, Update, Approve, Reject
+- Should have at least 3 words
+
+34 | post:
+35 |   operationId: example-post
+36 |   summary: summary looooong
+   |            ^^^^^^^^^^^^^^^^
+37 |   tags:
+38 |     - foo
+
+Error was generated by the operation-summary-length assertion rule.
+
+
+[5] openapi.yaml:1:1 at #/
+
+Every API should have security defined on the root level or for each operation.
+
+ 1 | openapi: 3.1.0
+   | ^^^^^^^^^^^^^^
+ 2 | servers:
+   | ^^^^^^^^
+ … | < 46 more lines >
+49 |           description: example description
+   |           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+50 |
+
+Error was generated by the security-defined rule.
+
+
+/openapi.yaml: validated in <test>ms
+
+❌ Validation failed with 5 errors.
+run \`openapi lint --generate-ignore-file\` to add all problems to the ignore file.
+
+
+`;
diff --git a/__tests__/lint/assertions-map-types/snapshot.js b/__tests__/lint/assertions-map-types/snapshot.js
index 46859870..28afe372 100644
--- a/__tests__/lint/assertions-map-types/snapshot.js
+++ b/__tests__/lint/assertions-map-types/snapshot.js
@@ -5,7 +5,7 @@ exports[`E2E lint assertions-map-types 1`] = `
 validating /openapi.yaml...
 [1] openapi.yaml:12:7 at #/servers/0/variables/Port
 
-The serverVariableMap assertion doesn't meet required conditions
+serverVariableMap assertion failed because the ServerVariablesMap didn't meet the assertions: "Port" should use flatcase
 
 10 | - url: http://petstore.swagger.io:{Port}/v1
 11 |   variables: # ServerVariablesMap
@@ -19,7 +19,7 @@ Error was generated by the serverVariableMap assertion rule.
 
 [2] openapi.yaml:29:15 at #/paths/~1pets/get/responses/200/headers/x-next
 
-The headerMap assertion doesn't meet required conditions
+headerMap assertion failed because the HeadersMap x-next didn't meet the assertions: Should be not defined
 
 27 | headers: # HeadersMap
 28 |   x-next:
@@ -37,7 +37,7 @@ Error was generated by the headerMap assertion rule.
 
 [3] openapi.yaml:35:17 at #/paths/~1pets/get/responses/200/content/application~1json/encoding/historyMetadata
 
-The encodingMap assertion doesn't meet required conditions
+encodingMap assertion failed because the EncodingsMap didn't meet the assertions: "historyMetadata" should use kebab-case
 
 33 |   application/json:
 34 |     encoding: # EncodingsMap
@@ -51,7 +51,7 @@ Error was generated by the encodingMap assertion rule.
 
 [4] openapi.yaml:37:11 at #/paths/~1pets/get/responses/200/links
 
-The linkMap assertion doesn't meet required conditions
+linkMap assertion failed because the LinksMap didn't meet the assertions: "address" should match a regex /^pet/
 
 35 |       historyMetadata:
 36 |         contentType: application/json; charset=utf-8
@@ -65,7 +65,7 @@ Error was generated by the linkMap assertion rule.
 
 [5] openapi.yaml:43:9 at #/paths/~1pets/get/callbacks/myCallback
 
-The callbackMap assertion doesn't meet required conditions
+callbackMap assertion failed because the CallbacksMap didn't meet the assertions: "myCallback" should use snake_case
 
 41 |           userId: $request.path.id
 42 | callbacks: # CallbacksMap
diff --git a/packages/core/src/__tests__/lint.test.ts b/packages/core/src/__tests__/lint.test.ts
index 714b261b..3aadaa6e 100644
--- a/packages/core/src/__tests__/lint.test.ts
+++ b/packages/core/src/__tests__/lint.test.ts
@@ -60,6 +60,22 @@ describe('lint', () => {
           no-invalid-media-type-examples: error
           path-http-verbs-order: error
           boolean-parameter-prefixes: off
+          assert/operation-summary-length:
+            subject: Operation
+            property: summary
+            message: Operation summary should start with an active verb
+            local/checkWordsCount: 
+              min: 3
+      features.openapi:
+        showConsole: true
+        layout:
+          scope: section
+        routingStrategy: browser
+        theme:
+          rightPanel:
+            backgroundColor: '#263238'
+          links:
+            color: '#6CC496'
         features.openapi:
           showConsole: true
           layout:
@@ -77,6 +93,78 @@ describe('lint', () => {
 
     expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
       Array [
+        Object {
+          "from": undefined,
+          "location": Array [
+            Object {
+              "pointer": "#/atures.openapi",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`atures.openapi\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": Array [
+            "features.openapi",
+          ],
+        },
+        Object {
+          "from": undefined,
+          "location": Array [
+            Object {
+              "pointer": "#/showConsole",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`showConsole\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": Array [],
+        },
+        Object {
+          "from": undefined,
+          "location": Array [
+            Object {
+              "pointer": "#/layout",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`layout\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": Array [],
+        },
+        Object {
+          "from": undefined,
+          "location": Array [
+            Object {
+              "pointer": "#/routingStrategy",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`routingStrategy\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": Array [],
+        },
+        Object {
+          "from": undefined,
+          "location": Array [
+            Object {
+              "pointer": "#/theme",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`theme\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": Array [],
+        },
         Object {
           "from": undefined,
           "location": Array [
diff --git a/packages/core/src/config/__tests__/config-resolvers.test.ts b/packages/core/src/config/__tests__/config-resolvers.test.ts
index bf62fd80..7d0ad24b 100644
--- a/packages/core/src/config/__tests__/config-resolvers.test.ts
+++ b/packages/core/src/config/__tests__/config-resolvers.test.ts
@@ -1,3 +1,5 @@
+import { colorize } from '../../logger';
+import { asserts } from '../../rules/common/assertions/asserts';
 import { resolveStyleguideConfig, resolveApis, resolveConfig } from '../config-resolvers';
 const path = require('path');
 
@@ -130,6 +132,40 @@ describe('resolveStyleguideConfig', () => {
     expect(styleguide).toMatchSnapshot();
   });
 
+  it('should resolve custom assertion from plugin', async () => {
+    const styleguideConfig = {
+      extends: ['local-config-with-custom-function.yaml'],
+    };
+    const { plugins } = await resolveStyleguideConfig({
+      styleguideConfig,
+      configPath,
+    });
+
+    expect(plugins).toBeDefined();
+    expect(plugins?.length).toBe(2);
+    expect(asserts['test-plugin/checkWordsCount']).toBeDefined();
+  });
+
+  it('should throw error when custom assertion load not exist plugin', async () => {
+    const styleguideConfig = {
+      extends: ['local-config-with-wrong-custom-function.yaml'],
+    };
+    try {
+      await resolveStyleguideConfig({
+        styleguideConfig,
+        configPath,
+      });
+    } catch (e) {
+      expect(e.message.toString()).toContain(
+        `Plugin ${colorize.red(
+          'test-plugin'
+        )} doesn't export assertions function with name ${colorize.red('checkWordsCount2')}.`
+      );
+    }
+
+    expect(asserts['test-plugin/checkWordsCount']).toBeDefined();
+  });
+
   it('should correctly merge assertions from nested config', async () => {
     const styleguideConfig = {
       extends: ['local-config-with-file.yaml'],
diff --git a/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-custom-function.yaml b/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-custom-function.yaml
new file mode 100644
index 00000000..5e3376f0
--- /dev/null
+++ b/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-custom-function.yaml
@@ -0,0 +1,16 @@
+lint:
+  rules:
+    no-invalid-media-type-examples: warn
+    operation-4xx-response: off
+    assert/tag-description:
+      subject: Tag
+      property: description
+      message: Tag description must have at least 3 words.
+      severity: error
+      test-plugin/checkWordsCount:
+        min: 3
+  plugins:
+    - plugin.js
+  extends:
+    - recommended
+    - test-plugin/all
diff --git a/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-wrong-custom-function.yaml b/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-wrong-custom-function.yaml
new file mode 100644
index 00000000..f6e44004
--- /dev/null
+++ b/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-wrong-custom-function.yaml
@@ -0,0 +1,16 @@
+lint:
+  rules:
+    no-invalid-media-type-examples: warn
+    operation-4xx-response: off
+    assert/tag-description:
+      subject: Tag
+      property: description
+      message: Tag description must have at least 3 words.
+      severity: error
+      test-plugin/checkWordsCount2:
+        min: 3
+  plugins:
+    - plugin.js
+  extends:
+    - recommended
+    - test-plugin/all
diff --git a/packages/core/src/config/__tests__/fixtures/resolve-config/plugin.js b/packages/core/src/config/__tests__/fixtures/resolve-config/plugin.js
index bde518bc..8d44ebf1 100644
--- a/packages/core/src/config/__tests__/fixtures/resolve-config/plugin.js
+++ b/packages/core/src/config/__tests__/fixtures/resolve-config/plugin.js
@@ -51,6 +51,16 @@ const decorators = {
   },
 };
 
+const assertions = {
+  checkWordsCount: (value, opts, location) => {
+    const words = value.split(' ');
+    if (words.length >= opts.min) {
+      return { isValid: true };
+    }
+    return { isValid: false, location };
+  },
+};
+
 const configs = {
   all: {
     rules: {
@@ -66,4 +76,5 @@ module.exports = {
   rules,
   decorators,
   configs,
+  assertions,
 };
diff --git a/packages/core/src/rules/common/assertions/__tests__/asserts.test.ts b/packages/core/src/rules/common/assertions/__tests__/asserts.test.ts
index 4dbf1661..91080d26 100644
--- a/packages/core/src/rules/common/assertions/__tests__/asserts.test.ts
+++ b/packages/core/src/rules/common/assertions/__tests__/asserts.test.ts
@@ -1,6 +1,6 @@
 import { Location } from '../../../../ref-utils';
 import { Source } from '../../../../resolve';
-import { asserts } from '../asserts';
+import { asserts, buildAssertCustomFunction } from '../asserts';
 
 let baseLocation = new Location(jest.fn() as any as Source, 'pointer');
 
@@ -14,61 +14,63 @@ describe('oas3 assertions', () => {
 
     describe('pattern', () => {
       it('value should match regex pattern', () => {
-        expect(asserts.pattern('test string', '/test/', baseLocation)).toEqual({ isValid: true });
-        expect(asserts.pattern('test string', '/test me/', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.pattern(['test string', 'test me'], '/test/', baseLocation)).toEqual({
-          isValid: true,
-        });
-        expect(asserts.pattern(['test string', 'test me'], '/test me/', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation.key(),
-        });
+        expect(asserts.pattern('test string', '/test/', baseLocation)).toEqual([]);
+        expect(asserts.pattern('test string', '/test me/', baseLocation)).toEqual([
+          { location: baseLocation, message: '"test string" should match a regex /test me/' },
+        ]);
+        expect(asserts.pattern(['test string', 'test me'], '/test/', baseLocation)).toEqual([]);
+        expect(asserts.pattern(['test string', 'test me'], '/test me/', baseLocation)).toEqual([
+          {
+            message: '"test string" should match a regex /test me/',
+            location: baseLocation.key(),
+          },
+        ]);
         expect(
           asserts.pattern(
             './components/smth/test.yaml',
             '/^(./)?components/.*.yaml$/',
             baseLocation
           )
-        ).toEqual({ isValid: true });
+        ).toEqual([]);
         expect(
           asserts.pattern('./other.yaml', '/^(./)?components/.*.yaml$/', baseLocation)
-        ).toEqual({ isValid: false, location: baseLocation });
+        ).toEqual([
+          {
+            message: '"./other.yaml" should match a regex /^(./)?components/.*.yaml$/',
+            location: baseLocation,
+          },
+        ]);
       });
     });
 
     describe('ref', () => {
       it('value should have ref', () => {
-        expect(asserts.ref({ $ref: 'text' }, true, baseLocation, { $ref: 'text' })).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
-        expect(asserts.ref({}, true, baseLocation, {})).toEqual({
-          isValid: false,
-          location: baseLocation.key(),
-        });
+        expect(asserts.ref({ $ref: 'text' }, true, baseLocation, { $ref: 'text' })).toEqual([]);
+        expect(asserts.ref({}, true, baseLocation, {})).toEqual([
+          {
+            message: 'should use $ref',
+            location: baseLocation.key(),
+          },
+        ]);
       });
 
       it('value should not have ref', () => {
-        expect(asserts.ref({ $ref: 'text' }, false, baseLocation, { $ref: 'text' })).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.ref({}, false, baseLocation, {})).toEqual({
-          isValid: true,
-          location: baseLocation.key(),
-        });
+        expect(asserts.ref({ $ref: 'text' }, false, baseLocation, { $ref: 'text' })).toEqual([
+          {
+            message: 'should not use $ref',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.ref({}, false, baseLocation, {})).toEqual([]);
       });
 
       it('value should match regex pattern', () => {
         expect(
           asserts.ref({ $ref: 'test string' }, '/test/', baseLocation, { $ref: 'test string' })
-        ).toEqual({ isValid: true, location: baseLocation });
+        ).toEqual([]);
         expect(
           asserts.ref({ $ref: 'test string' }, '/test me/', baseLocation, { $ref: 'test string' })
-        ).toEqual({ isValid: false, location: baseLocation });
+        ).toEqual([{ message: '$ref value should match /test me/', location: baseLocation }]);
         expect(
           asserts.ref(
             { $ref: './components/smth/test.yaml' },
@@ -76,7 +78,7 @@ describe('oas3 assertions', () => {
             baseLocation,
             { $ref: './components/smth/test.yaml' }
           )
-        ).toEqual({ isValid: true, location: baseLocation });
+        ).toEqual([]);
         expect(
           asserts.ref(
             { $ref: './paths/smth/test.yaml' },
@@ -84,420 +86,458 @@ describe('oas3 assertions', () => {
             baseLocation,
             { $ref: './paths/smth/test.yaml' }
           )
-        ).toEqual({ isValid: false, location: baseLocation });
+        ).toEqual([
+          {
+            message: '$ref value should match /^(./)?components/.*.yaml$/',
+            location: baseLocation,
+          },
+        ]);
       });
     });
 
     describe('enum', () => {
       it('value should be among predefined keys', () => {
-        expect(asserts.enum('test', ['test', 'example'], baseLocation)).toEqual({ isValid: true });
-        expect(asserts.enum(['test'], ['test', 'example'], baseLocation)).toEqual({
-          isValid: true,
-        });
-        expect(asserts.enum(['test', 'example'], ['test', 'example'], baseLocation)).toEqual({
-          isValid: true,
-        });
+        expect(asserts.enum('test', ['test', 'example'], baseLocation)).toEqual([]);
+        expect(asserts.enum(['test'], ['test', 'example'], baseLocation)).toEqual([]);
+        expect(asserts.enum(['test', 'example'], ['test', 'example'], baseLocation)).toEqual([]);
         expect(asserts.enum(['test', 'example', 'foo'], ['test', 'example'], baseLocation)).toEqual(
-          { isValid: false, location: baseLocation.child('foo').key() }
+          [
+            {
+              message: '"foo" should be one of the predefined values',
+              location: baseLocation.child('foo').key(),
+            },
+          ]
         );
-        expect(asserts.enum('test', ['foo', 'example'], baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.enum(['test', 'foo'], ['test', 'example'], baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation.child('foo').key(),
-        });
+        expect(asserts.enum('test', ['foo', 'example'], baseLocation)).toEqual([
+          {
+            message: '"test" should be one of the predefined values',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.enum(['test', 'foo'], ['test', 'example'], baseLocation)).toEqual([
+          {
+            message: '"foo" should be one of the predefined values',
+            location: baseLocation.child('foo').key(),
+          },
+        ]);
       });
     });
 
     describe('defined', () => {
       it('value should be defined', () => {
-        expect(asserts.defined('test', true, baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
-        expect(asserts.defined(undefined, true, baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
+        expect(asserts.defined('test', true, baseLocation)).toEqual([]);
+        expect(asserts.defined(undefined, true, baseLocation)).toEqual([
+          {
+            message: 'Should be defined',
+            location: baseLocation,
+          },
+        ]);
       });
       it('value should be undefined', () => {
-        expect(asserts.defined(undefined, false, baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
-        expect(asserts.defined('test', false, baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
+        expect(asserts.defined(undefined, false, baseLocation)).toEqual([]);
+        expect(asserts.defined('test', false, baseLocation)).toEqual([
+          {
+            message: 'Should be not defined',
+            location: baseLocation,
+          },
+        ]);
       });
     });
 
     describe('undefined', () => {
       it('value should be undefined', () => {
-        expect(asserts.undefined(undefined, true, baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
-        expect(asserts.undefined('test', true, baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
+        expect(asserts.undefined(undefined, true, baseLocation)).toEqual([]);
+        expect(asserts.undefined('test', true, baseLocation)).toEqual([
+          {
+            message: 'Should not be defined',
+            location: baseLocation,
+          },
+        ]);
       });
       it('value should be defined', () => {
-        expect(asserts.undefined('test', false, baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
-        expect(asserts.undefined(undefined, false, baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
+        expect(asserts.undefined('test', false, baseLocation)).toEqual([]);
+        expect(asserts.undefined(undefined, false, baseLocation)).toEqual([
+          {
+            message: 'Should be defined',
+            location: baseLocation,
+          },
+        ]);
       });
     });
 
     describe('required', () => {
       it('values should be required', () => {
-        expect(asserts.required(['one', 'two', 'three'], ['one', 'two'], baseLocation)).toEqual({
-          isValid: true,
-        });
-        expect(asserts.required(['one', 'two'], ['one', 'two', 'three'], baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation.key(),
-        });
+        expect(asserts.required(['one', 'two', 'three'], ['one', 'two'], baseLocation)).toEqual([]);
+        expect(asserts.required(['one', 'two'], ['one', 'two', 'three'], baseLocation)).toEqual([
+          {
+            message: 'three is required',
+            location: baseLocation.key(),
+          },
+        ]);
       });
     });
 
     describe('nonEmpty', () => {
       it('value should not be empty', () => {
-        expect(asserts.nonEmpty('test', true, baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
-        expect(asserts.nonEmpty('', true, baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.nonEmpty(null, true, baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.nonEmpty(undefined, true, baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
+        expect(asserts.nonEmpty('test', true, baseLocation)).toEqual([]);
+        expect(asserts.nonEmpty('', true, baseLocation)).toEqual([
+          {
+            message: 'Should not be empty',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.nonEmpty(null, true, baseLocation)).toEqual([
+          {
+            message: 'Should not be empty',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.nonEmpty(undefined, true, baseLocation)).toEqual([
+          {
+            message: 'Should not be empty',
+            location: baseLocation,
+          },
+        ]);
       });
       it('value should be empty', () => {
-        expect(asserts.nonEmpty('', false, baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
-        expect(asserts.nonEmpty(null, false, baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
-        expect(asserts.nonEmpty(undefined, false, baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
-        expect(asserts.nonEmpty('test', false, baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
+        expect(asserts.nonEmpty('', false, baseLocation)).toEqual([]);
+        expect(asserts.nonEmpty(null, false, baseLocation)).toEqual([]);
+        expect(asserts.nonEmpty(undefined, false, baseLocation)).toEqual([]);
+        expect(asserts.nonEmpty('test', false, baseLocation)).toEqual([
+          {
+            message: 'Should be empty',
+            location: baseLocation,
+          },
+        ]);
       });
     });
 
     describe('minLength', () => {
       it('value should have less or equal than 5 symbols length', () => {
-        expect(asserts.minLength('test', 5, baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.minLength([1, 2, 3, 4], 5, baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.minLength([1, 2, 3, 4, 5], 5, baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
-        expect(asserts.minLength([1, 2, 3, 4, 5, 6], 5, baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
-        expect(asserts.minLength('example', 5, baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
-        expect(asserts.minLength([], 5, baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.minLength('', 5, baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
+        expect(asserts.minLength('test', 5, baseLocation)).toEqual([
+          {
+            message: 'Should have at least 5 characters',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.minLength([1, 2, 3, 4], 5, baseLocation)).toEqual([
+          {
+            message: 'Should have at least 5 characters',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.minLength([1, 2, 3, 4, 5], 5, baseLocation)).toEqual([]);
+        expect(asserts.minLength([1, 2, 3, 4, 5, 6], 5, baseLocation)).toEqual([]);
+        expect(asserts.minLength('example', 5, baseLocation)).toEqual([]);
+        expect(asserts.minLength([], 5, baseLocation)).toEqual([
+          {
+            message: 'Should have at least 5 characters',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.minLength('', 5, baseLocation)).toEqual([
+          {
+            message: 'Should have at least 5 characters',
+            location: baseLocation,
+          },
+        ]);
       });
     });
 
     describe('maxLength', () => {
       it('value should have more or equal than 5 symbols length', () => {
-        expect(asserts.maxLength('test', 5, baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
-        expect(asserts.maxLength([1, 2, 3, 4], 5, baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
-        expect(asserts.maxLength([1, 2, 3, 4, 5], 5, baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
-        expect(asserts.maxLength([1, 2, 3, 4, 5, 6], 5, baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.maxLength('example', 5, baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.maxLength([], 5, baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
-        expect(asserts.maxLength('', 5, baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
+        expect(asserts.maxLength('test', 5, baseLocation)).toEqual([]);
+        expect(asserts.maxLength([1, 2, 3, 4], 5, baseLocation)).toEqual([]);
+        expect(asserts.maxLength([1, 2, 3, 4, 5], 5, baseLocation)).toEqual([]);
+        expect(asserts.maxLength([1, 2, 3, 4, 5, 6], 5, baseLocation)).toEqual([
+          {
+            message: 'Should have at most 5 characters',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.maxLength('example', 5, baseLocation)).toEqual([
+          {
+            message: 'Should have at most 5 characters',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.maxLength([], 5, baseLocation)).toEqual([]);
+        expect(asserts.maxLength('', 5, baseLocation)).toEqual([]);
       });
     });
 
     describe('casing', () => {
       it('value should be camelCase', () => {
-        expect(asserts.casing(['testExample', 'fooBar'], 'camelCase', baseLocation)).toEqual({
-          isValid: true,
-        });
-        expect(asserts.casing(['testExample', 'FooBar'], 'camelCase', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation.child('FooBar').key(),
-        });
-        expect(asserts.casing('testExample', 'camelCase', baseLocation)).toEqual({ isValid: true });
-        expect(asserts.casing('TestExample', 'camelCase', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('test-example', 'camelCase', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('test_example', 'camelCase', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
+        expect(asserts.casing(['testExample', 'fooBar'], 'camelCase', baseLocation)).toEqual([]);
+        expect(asserts.casing(['testExample', 'FooBar'], 'camelCase', baseLocation)).toEqual([
+          {
+            message: '"FooBar" should use camelCase',
+            location: baseLocation.child('FooBar').key(),
+          },
+        ]);
+        expect(asserts.casing('testExample', 'camelCase', baseLocation)).toEqual([]);
+        expect(asserts.casing('TestExample', 'camelCase', baseLocation)).toEqual([
+          {
+            message: '"TestExample" should use camelCase',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('test-example', 'camelCase', baseLocation)).toEqual([
+          {
+            message: '"test-example" should use camelCase',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('test_example', 'camelCase', baseLocation)).toEqual([
+          {
+            message: '"test_example" should use camelCase',
+            location: baseLocation,
+          },
+        ]);
       });
       it('value should be PascalCase', () => {
-        expect(asserts.casing('TestExample', 'PascalCase', baseLocation)).toEqual({
-          isValid: true,
-        });
-        expect(asserts.casing(['TestExample', 'FooBar'], 'PascalCase', baseLocation)).toEqual({
-          isValid: true,
-        });
-        expect(asserts.casing(['TestExample', 'fooBar'], 'PascalCase', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation.child('fooBar').key(),
-        });
-        expect(asserts.casing('testExample', 'PascalCase', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('test-example', 'PascalCase', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('test_example', 'PascalCase', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
+        expect(asserts.casing('TestExample', 'PascalCase', baseLocation)).toEqual([]);
+        expect(asserts.casing(['TestExample', 'FooBar'], 'PascalCase', baseLocation)).toEqual([]);
+        expect(asserts.casing(['TestExample', 'fooBar'], 'PascalCase', baseLocation)).toEqual([
+          {
+            message: '"fooBar" should use PascalCase',
+            location: baseLocation.child('fooBar').key(),
+          },
+        ]);
+        expect(asserts.casing('testExample', 'PascalCase', baseLocation)).toEqual([
+          {
+            message: '"testExample" should use PascalCase',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('test-example', 'PascalCase', baseLocation)).toEqual([
+          {
+            message: '"test-example" should use PascalCase',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('test_example', 'PascalCase', baseLocation)).toEqual([
+          {
+            message: '"test_example" should use PascalCase',
+            location: baseLocation,
+          },
+        ]);
       });
       it('value should be kebab-case', () => {
-        expect(asserts.casing('test-example', 'kebab-case', baseLocation)).toEqual({
-          isValid: true,
-        });
-        expect(asserts.casing(['test-example', 'foo-bar'], 'kebab-case', baseLocation)).toEqual({
-          isValid: true,
-        });
-        expect(asserts.casing(['test-example', 'foo_bar'], 'kebab-case', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation.child('foo_bar').key(),
-        });
-        expect(asserts.casing('testExample', 'kebab-case', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('TestExample', 'kebab-case', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('test_example', 'kebab-case', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
+        expect(asserts.casing('test-example', 'kebab-case', baseLocation)).toEqual([]);
+        expect(asserts.casing(['test-example', 'foo-bar'], 'kebab-case', baseLocation)).toEqual([]);
+        expect(asserts.casing(['test-example', 'foo_bar'], 'kebab-case', baseLocation)).toEqual([
+          {
+            message: '"foo_bar" should use kebab-case',
+            location: baseLocation.child('foo_bar').key(),
+          },
+        ]);
+        expect(asserts.casing('testExample', 'kebab-case', baseLocation)).toEqual([
+          {
+            message: '"testExample" should use kebab-case',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('TestExample', 'kebab-case', baseLocation)).toEqual([
+          {
+            message: '"TestExample" should use kebab-case',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('test_example', 'kebab-case', baseLocation)).toEqual([
+          {
+            message: '"test_example" should use kebab-case',
+            location: baseLocation,
+          },
+        ]);
       });
       it('value should be snake_case', () => {
-        expect(asserts.casing('test_example', 'snake_case', baseLocation)).toEqual({
-          isValid: true,
-        });
-        expect(asserts.casing(['test_example', 'foo_bar'], 'snake_case', baseLocation)).toEqual({
-          isValid: true,
-        });
-        expect(asserts.casing(['test_example', 'foo-bar'], 'snake_case', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation.child('foo-bar').key(),
-        });
-        expect(asserts.casing('testExample', 'snake_case', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('TestExample', 'snake_case', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('test-example', 'snake_case', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
+        expect(asserts.casing('test_example', 'snake_case', baseLocation)).toEqual([]);
+        expect(asserts.casing(['test_example', 'foo_bar'], 'snake_case', baseLocation)).toEqual([]);
+        expect(asserts.casing(['test_example', 'foo-bar'], 'snake_case', baseLocation)).toEqual([
+          {
+            message: '"foo-bar" should use snake_case',
+            location: baseLocation.child('foo-bar').key(),
+          },
+        ]);
+        expect(asserts.casing('testExample', 'snake_case', baseLocation)).toEqual([
+          {
+            message: '"testExample" should use snake_case',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('TestExample', 'snake_case', baseLocation)).toEqual([
+          {
+            message: '"TestExample" should use snake_case',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('test-example', 'snake_case', baseLocation)).toEqual([
+          {
+            message: '"test-example" should use snake_case',
+            location: baseLocation,
+          },
+        ]);
       });
       it('value should be MACRO_CASE', () => {
-        expect(asserts.casing('TEST_EXAMPLE', 'MACRO_CASE', baseLocation)).toEqual({
-          isValid: true,
-        });
-        expect(asserts.casing(['TEST_EXAMPLE', 'FOO_BAR'], 'MACRO_CASE', baseLocation)).toEqual({
-          isValid: true,
-        });
-        expect(asserts.casing(['TEST_EXAMPLE', 'FOO-BAR'], 'MACRO_CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation.child('FOO-BAR').key(),
-        });
-        expect(asserts.casing('TEST_EXAMPLE_', 'MACRO_CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('_TEST_EXAMPLE', 'MACRO_CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('TEST__EXAMPLE', 'MACRO_CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('TEST-EXAMPLE', 'MACRO_CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('testExample', 'MACRO_CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('TestExample', 'MACRO_CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('test-example', 'MACRO_CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
+        expect(asserts.casing('TEST_EXAMPLE', 'MACRO_CASE', baseLocation)).toEqual([]);
+        expect(asserts.casing(['TEST_EXAMPLE', 'FOO_BAR'], 'MACRO_CASE', baseLocation)).toEqual([]);
+        expect(asserts.casing(['TEST_EXAMPLE', 'FOO-BAR'], 'MACRO_CASE', baseLocation)).toEqual([
+          {
+            message: '"FOO-BAR" should use MACRO_CASE',
+            location: baseLocation.child('FOO-BAR').key(),
+          },
+        ]);
+        expect(asserts.casing('TEST_EXAMPLE_', 'MACRO_CASE', baseLocation)).toEqual([
+          {
+            message: '"TEST_EXAMPLE_" should use MACRO_CASE',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('_TEST_EXAMPLE', 'MACRO_CASE', baseLocation)).toEqual([
+          {
+            message: '"_TEST_EXAMPLE" should use MACRO_CASE',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('TEST__EXAMPLE', 'MACRO_CASE', baseLocation)).toEqual([
+          {
+            message: '"TEST__EXAMPLE" should use MACRO_CASE',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('TEST-EXAMPLE', 'MACRO_CASE', baseLocation)).toEqual([
+          {
+            message: '"TEST-EXAMPLE" should use MACRO_CASE',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('testExample', 'MACRO_CASE', baseLocation)).toEqual([
+          {
+            message: '"testExample" should use MACRO_CASE',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('TestExample', 'MACRO_CASE', baseLocation)).toEqual([
+          {
+            message: '"TestExample" should use MACRO_CASE',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('test-example', 'MACRO_CASE', baseLocation)).toEqual([
+          {
+            message: '"test-example" should use MACRO_CASE',
+            location: baseLocation,
+          },
+        ]);
       });
       it('value should be COBOL-CASE', () => {
-        expect(asserts.casing('TEST-EXAMPLE', 'COBOL-CASE', baseLocation)).toEqual({
-          isValid: true,
-        });
-        expect(asserts.casing(['TEST-EXAMPLE', 'FOO-BAR'], 'COBOL-CASE', baseLocation)).toEqual({
-          isValid: true,
-        });
-        expect(asserts.casing(['TEST-EXAMPLE', 'FOO_BAR'], 'COBOL-CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation.child('FOO_BAR').key(),
-        });
-        expect(asserts.casing('TEST-EXAMPLE-', 'COBOL-CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('0TEST-EXAMPLE', 'COBOL-CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('-TEST-EXAMPLE', 'COBOL-CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('TEST--EXAMPLE', 'COBOL-CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('TEST_EXAMPLE', 'COBOL-CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('testExample', 'COBOL-CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('TestExample', 'COBOL-CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('test-example', 'COBOL-CASE', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
+        expect(asserts.casing('TEST-EXAMPLE', 'COBOL-CASE', baseLocation)).toEqual([]);
+        expect(asserts.casing(['TEST-EXAMPLE', 'FOO-BAR'], 'COBOL-CASE', baseLocation)).toEqual([]);
+        expect(asserts.casing(['TEST-EXAMPLE', 'FOO_BAR'], 'COBOL-CASE', baseLocation)).toEqual([
+          {
+            message: '"FOO_BAR" should use COBOL-CASE',
+            location: baseLocation.child('FOO_BAR').key(),
+          },
+        ]);
+        expect(asserts.casing('TEST-EXAMPLE-', 'COBOL-CASE', baseLocation)).toEqual([
+          {
+            message: '"TEST-EXAMPLE-" should use COBOL-CASE',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('0TEST-EXAMPLE', 'COBOL-CASE', baseLocation)).toEqual([
+          {
+            message: '"0TEST-EXAMPLE" should use COBOL-CASE',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('-TEST-EXAMPLE', 'COBOL-CASE', baseLocation)).toEqual([
+          {
+            message: '"-TEST-EXAMPLE" should use COBOL-CASE',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('TEST--EXAMPLE', 'COBOL-CASE', baseLocation)).toEqual([
+          {
+            message: '"TEST--EXAMPLE" should use COBOL-CASE',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('TEST_EXAMPLE', 'COBOL-CASE', baseLocation)).toEqual([
+          {
+            message: '"TEST_EXAMPLE" should use COBOL-CASE',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('testExample', 'COBOL-CASE', baseLocation)).toEqual([
+          {
+            message: '"testExample" should use COBOL-CASE',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('TestExample', 'COBOL-CASE', baseLocation)).toEqual([
+          {
+            message: '"TestExample" should use COBOL-CASE',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('test-example', 'COBOL-CASE', baseLocation)).toEqual([
+          {
+            message: '"test-example" should use COBOL-CASE',
+            location: baseLocation,
+          },
+        ]);
       });
       it('value should be flatcase', () => {
-        expect(asserts.casing('testexample', 'flatcase', baseLocation)).toEqual({ isValid: true });
-        expect(asserts.casing(['testexample', 'foobar'], 'flatcase', baseLocation)).toEqual({
-          isValid: true,
-        });
-        expect(asserts.casing(['testexample', 'foo_bar'], 'flatcase', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation.child('foo_bar').key(),
-        });
-        expect(asserts.casing('testexample_', 'flatcase', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('0testexample', 'flatcase', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('testExample', 'flatcase', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('TestExample', 'flatcase', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
-        expect(asserts.casing('test-example', 'flatcase', baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation,
-        });
+        expect(asserts.casing('testexample', 'flatcase', baseLocation)).toEqual([]);
+        expect(asserts.casing(['testexample', 'foobar'], 'flatcase', baseLocation)).toEqual([]);
+        expect(asserts.casing(['testexample', 'foo_bar'], 'flatcase', baseLocation)).toEqual([
+          {
+            message: '"foo_bar" should use flatcase',
+            location: baseLocation.child('foo_bar').key(),
+          },
+        ]);
+        expect(asserts.casing('testexample_', 'flatcase', baseLocation)).toEqual([
+          {
+            message: '"testexample_" should use flatcase',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('0testexample', 'flatcase', baseLocation)).toEqual([
+          {
+            message: '"0testexample" should use flatcase',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('testExample', 'flatcase', baseLocation)).toEqual([
+          {
+            message: '"testExample" should use flatcase',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('TestExample', 'flatcase', baseLocation)).toEqual([
+          {
+            message: '"TestExample" should use flatcase',
+            location: baseLocation,
+          },
+        ]);
+        expect(asserts.casing('test-example', 'flatcase', baseLocation)).toEqual([
+          {
+            message: '"test-example" should use flatcase',
+            location: baseLocation,
+          },
+        ]);
       });
     });
 
     describe.skip('sortOrder', () => {
       it('value should be ordered in ASC direction', () => {
-        expect(asserts.sortOrder(['example', 'foo', 'test'], 'asc', baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
+        expect(asserts.sortOrder(['example', 'foo', 'test'], 'asc', baseLocation)).toEqual([]);
         expect(
           asserts.sortOrder(['example', 'foo', 'test'], { direction: 'asc' }, baseLocation)
-        ).toEqual({ isValid: true, location: baseLocation });
-        expect(asserts.sortOrder(['example'], 'asc', baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
+        ).toEqual([]);
+        expect(asserts.sortOrder(['example'], 'asc', baseLocation)).toEqual([]);
         expect(asserts.sortOrder(['example', 'test', 'foo'], 'asc', baseLocation)).toEqual({
           isValid: false,
           location: baseLocation,
@@ -512,7 +552,7 @@ describe('oas3 assertions', () => {
             { direction: 'asc', property: 'name' },
             baseLocation
           )
-        ).toEqual({ isValid: true, location: baseLocation });
+        ).toEqual([]);
         expect(
           asserts.sortOrder(
             [{ name: 'bar' }, { name: 'baz' }, { name: 'foo' }],
@@ -522,17 +562,11 @@ describe('oas3 assertions', () => {
         ).toEqual({ isValid: false, location: baseLocation });
       });
       it('value should be ordered in DESC direction', () => {
-        expect(asserts.sortOrder(['test', 'foo', 'example'], 'desc', baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
+        expect(asserts.sortOrder(['test', 'foo', 'example'], 'desc', baseLocation)).toEqual([]);
         expect(
           asserts.sortOrder(['test', 'foo', 'example'], { direction: 'desc' }, baseLocation)
-        ).toEqual({ isValid: true, location: baseLocation });
-        expect(asserts.sortOrder(['example'], 'desc', baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation,
-        });
+        ).toEqual([]);
+        expect(asserts.sortOrder(['example'], 'desc', baseLocation)).toEqual([]);
         expect(asserts.sortOrder(['example', 'test', 'foo'], 'desc', baseLocation)).toEqual({
           isValid: false,
           location: baseLocation,
@@ -547,7 +581,7 @@ describe('oas3 assertions', () => {
             { direction: 'desc', property: 'name' },
             baseLocation
           )
-        ).toEqual({ isValid: true, location: baseLocation });
+        ).toEqual([]);
         expect(
           asserts.sortOrder(
             [{ name: 'foo' }, { name: 'baz' }, { name: 'bar' }],
@@ -562,17 +596,21 @@ describe('oas3 assertions', () => {
       it('node should not have more than one property from predefined list', () => {
         expect(
           asserts.mutuallyExclusive(Object.keys(fakeNode), ['foo', 'test'], baseLocation)
-        ).toEqual({ isValid: true, location: baseLocation.key() });
-        expect(asserts.mutuallyExclusive(Object.keys(fakeNode), [], baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation.key(),
-        });
+        ).toEqual([]);
+        expect(asserts.mutuallyExclusive(Object.keys(fakeNode), [], baseLocation)).toEqual([]);
         expect(
           asserts.mutuallyExclusive(Object.keys(fakeNode), ['foo', 'bar'], baseLocation)
-        ).toEqual({ isValid: false, location: baseLocation.key() });
+        ).toEqual([
+          { message: 'foo, bar keys should be mutually exclusive', location: baseLocation.key() },
+        ]);
         expect(
           asserts.mutuallyExclusive(Object.keys(fakeNode), ['foo', 'bar', 'test'], baseLocation)
-        ).toEqual({ isValid: false, location: baseLocation.key() });
+        ).toEqual([
+          {
+            message: 'foo, bar, test keys should be mutually exclusive',
+            location: baseLocation.key(),
+          },
+        ]);
       });
     });
 
@@ -580,48 +618,73 @@ describe('oas3 assertions', () => {
       it('node should have all the properties from predefined list', () => {
         expect(
           asserts.mutuallyRequired(Object.keys(fakeNode), ['foo', 'bar'], baseLocation)
-        ).toEqual({ isValid: true, location: baseLocation.key() });
+        ).toEqual([]);
         expect(
           asserts.mutuallyRequired(Object.keys(fakeNode), ['foo', 'bar', 'baz'], baseLocation)
-        ).toEqual({ isValid: true, location: baseLocation.key() });
-        expect(asserts.mutuallyRequired(Object.keys(fakeNode), [], baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation.key(),
-        });
+        ).toEqual([]);
+        expect(asserts.mutuallyRequired(Object.keys(fakeNode), [], baseLocation)).toEqual([]);
         expect(
           asserts.mutuallyRequired(Object.keys(fakeNode), ['foo', 'test'], baseLocation)
-        ).toEqual({ isValid: false, location: baseLocation.key() });
+        ).toEqual([
+          { message: 'Properties foo, test are mutually required', location: baseLocation.key() },
+        ]);
         expect(
           asserts.mutuallyRequired(Object.keys(fakeNode), ['foo', 'bar', 'test'], baseLocation)
-        ).toEqual({ isValid: false, location: baseLocation.key() });
+        ).toEqual([
+          {
+            message: 'Properties foo, bar, test are mutually required',
+            location: baseLocation.key(),
+          },
+        ]);
       });
     });
 
     describe('requireAny', () => {
       it('node must have at least one property from predefined list', () => {
-        expect(asserts.requireAny(Object.keys(fakeNode), ['foo', 'test'], baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation.key(),
-        });
-        expect(asserts.requireAny(Object.keys(fakeNode), ['test', 'bar'], baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation.key(),
-        });
-        expect(asserts.requireAny(Object.keys(fakeNode), [], baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation.key(),
-        });
-        expect(asserts.requireAny(Object.keys(fakeNode), ['test', 'test1'], baseLocation)).toEqual({
-          isValid: false,
-          location: baseLocation.key(),
-        });
-        expect(asserts.requireAny(Object.keys(fakeNode), ['foo', 'bar'], baseLocation)).toEqual({
-          isValid: true,
-          location: baseLocation.key(),
-        });
+        expect(asserts.requireAny(Object.keys(fakeNode), ['foo', 'test'], baseLocation)).toEqual(
+          []
+        );
+        expect(asserts.requireAny(Object.keys(fakeNode), ['test', 'bar'], baseLocation)).toEqual(
+          []
+        );
+        expect(asserts.requireAny(Object.keys(fakeNode), [], baseLocation)).toEqual([
+          {
+            message: 'Should have any of ',
+            location: baseLocation.key(),
+          },
+        ]);
+        expect(asserts.requireAny(Object.keys(fakeNode), ['test', 'test1'], baseLocation)).toEqual([
+          {
+            message: 'Should have any of test, test1',
+            location: baseLocation.key(),
+          },
+        ]);
+        expect(asserts.requireAny(Object.keys(fakeNode), ['foo', 'bar'], baseLocation)).toEqual([]);
         expect(
           asserts.requireAny(Object.keys(fakeNode), ['foo', 'bar', 'test'], baseLocation)
-        ).toEqual({ isValid: true, location: baseLocation.key() });
+        ).toEqual([]);
+      });
+    });
+
+    describe('function', () => {
+      it('node must have at least one property from predefined list', () => {
+        const customFn = jest.fn((value: string[], options: any, location: Location) => {
+          if (value[0] === options.word) {
+            return [{ message: `First value should be ${options.word}`, location: location.key() }];
+          }
+          return [];
+        });
+        asserts['local/customFn'] = buildAssertCustomFunction(customFn);
+        expect(
+          asserts['local/customFn'](Object.keys(fakeNode), { word: 'foo' }, baseLocation)
+        ).toEqual([
+          {
+            message: 'First value should be foo',
+            location: baseLocation.key(),
+          },
+        ]);
+        expect(customFn.mock.calls.length).toBe(1);
+        expect(customFn.mock.calls[0][0]).toEqual(Object.keys(fakeNode));
       });
     });
   });