Repository: Redocly/redocly-cli
Pull request #1661 (2024-08-09 15:45:36)

feat: add support for a plugin interface common with Realm
## What/Why/How?

Unifying plugin interface for CLI and Realm:

* Change the plugins export from objects to a function
* Add support for ESM plugins
* Add support for plugins imported from NPM packages directly as opposed to a path in the `node_modules` directory: `./node_modules/package-name/plugin.js` --> `package-name/plugin.js`
* Keep backwards compatibility with the old plugin format

Examples:

***New CommonJS format***

```js
module.exports = () => {
  return {
    id: 'my-plugin',
    // CLI properties
    configs: {...},
    rules: {...},
    decorators: {...},
    preprocessors: {...},
    typeExtensions: {...},
    assertions: {...},

    // Realm properties
    processContent: (actions, context) => {},
    afterRoutesCreated: (actions, context) => {},
    loaders: {...},
    ejectIgnore: [],
    // JSONSchema
    ssoConfigSchema: {...},
    redoclyConfigSchema: {...},
  }
}
```

***New ESM format***

```js
export default function myPlugin () {
  return {
    id: 'my-plugin',
    // CLI properties
    configs: {...},
    rules: {...},
    decorators: {...},
    preprocessors: {...},
    typeExtensions: {...},
    assertions: {...},

    // Realm properties
    processContent: (actions, context) => {},
    afterRoutesCreated: (actions, context) => {},
    loaders: {...},
    ejectIgnore: [],
    // JSONSchema
    ssoConfigSchema: {...},
    redoclyConfigSchema: {...},
  }
}
```

## Reference

## Testing

## Screenshots (optional)

## Check yourself

- [x] Code changed? - Tested with redoc/reference-docs/workflows (internal)
- [x] All new/updated code is covered with tests
- [ ] New package installed? - Tested in different environments (browser/node)

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: f1098d4b19de3757343aa0b13caaf51d07462165

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/Redocly/redocly-cli/new/chore/universal-plugin-interface?filename=.changeset/lucky-lamps-begin.md&value=---%0A%22%40redocly%2Fopenapi-core%22%3A%20patch%0A---%0A%0Achore%3A%20add%20support%20for%20a%20plugin%20interface%20common%20with%20Realm%0A)


github-actions[bot]: | Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `redocly lint packages/core/src/benchmark/benches/rebilly.yaml` | 976.9 ± 21.7 | 947.2 | 1014.8 | 1.00 |
| `redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml` | 978.9 ± 11.8 | 965.7 | 1009.9 | 1.00 ± 0.03 |

<!-- thollander/actions-comment-pull-request "latest-vs-next-comparison" -->
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 | 77.53%     |    4692/6052    |
|                                                                           🟡                                                                           | Branches   | 65.97%     |    1950/2956    |
|                                                                           🟡                                                                           | Functions  | 71.42%     |     767/1074    |
|                                                                           🟡                                                                           | Lines      | 77.74%     |    4428/5696    |


<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/config/utils.ts">`...` / utils.ts</div>                       | 96.97%     | 95.7%                                                         | <div title="Base coverage is: 100%">94.44% (-5.56% 🔻)</div> | 96.9%  |
|                                                                           🟢                                                                           | <div title="core/src/config/config-resolvers.ts">`...` / config-resolvers.ts</div> | 84.08%     | <div title="Base coverage is: 67.23%">65.93% (-1.3% 🔻)</div> | 100%                                                         | 84.69% |

</details>

## Test suite run success
759 tests passing in 107 suites.

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

volodymyr-rutskyi: > Have the updated code samples been tested or does the reviewer need to do that? I added a few questions and some notes that the code changes needed accompanying text changes.

I tested the code samples when changing them.

Patch:
diff --git a/.changeset/famous-socks-rule.md b/.changeset/famous-socks-rule.md
new file mode 100644
index 00000000..745177b3
--- /dev/null
+++ b/.changeset/famous-socks-rule.md
@@ -0,0 +1,6 @@
+---
+"@redocly/openapi-core": minor
+"@redocly/cli": minor
+---
+
+Added support for ESM plugins and importing of plugins directly from npm package: `@vendor/package/plugin.js` instead of `./node_modules/@vendor/package/plugin.js`.
diff --git a/.changeset/tough-gifts-tell.md b/.changeset/tough-gifts-tell.md
new file mode 100644
index 00000000..94d7b44f
--- /dev/null
+++ b/.changeset/tough-gifts-tell.md
@@ -0,0 +1,6 @@
+---
+"@redocly/openapi-core": minor
+"@redocly/cli": minor
+---
+
+Changed plugins format to export a function instead of an object for compatibility with other Redocly products. The backwards compatibility with an old format of plugins is maintained but will be removed in the future.
diff --git a/docs/custom-plugins/custom-config.md b/docs/custom-plugins/custom-config.md
index 26e4f6f8..c4c11232 100644
--- a/docs/custom-plugins/custom-config.md
+++ b/docs/custom-plugins/custom-config.md
@@ -9,22 +9,24 @@ configuration file.
 The following is an example plugin, defining two configuration bundles:
 
 ```js
-module.exports = {
-  id: 'my-local-plugin'
-  configs: {
-    all: {
-      rules: {
-        'operation-id-not-test': 'error',
-        'boolean-parameter-prefixes': 'error',
+module.exports = function myLocalPlugin() {
+  return {
+    id: 'my-local-plugin',
+    configs: {
+      all: {
+        rules: {
+          'operation-id-not-test': 'error',
+          'boolean-parameter-prefixes': 'error',
+        },
       },
-    },
-    minimal: {
-      rules: {
-        'operation-id-not-test': 'off',
-        'boolean-parameter-prefixes': 'error',
+      minimal: {
+        rules: {
+          'operation-id-not-test': 'off',
+          'boolean-parameter-prefixes': 'error',
+        },
       },
-    }
-  }
+    },
+  };
 };
 ```
 
diff --git a/docs/custom-plugins/custom-decorators.md b/docs/custom-plugins/custom-decorators.md
index e4649ed3..c05ec58b 100644
--- a/docs/custom-plugins/custom-decorators.md
+++ b/docs/custom-plugins/custom-decorators.md
@@ -13,26 +13,29 @@ Decorators and preprocessors are the same in structure, but preprocessors are ru
 
 ## Plugin structure
 
-To create a preprocessor or decorator, the object that is exported from your module has to conform to an interface such as the following example:
+To create a preprocessor or decorator, the function that is exported from your module has to conform to an interface such as the following example:
 
 ```js
-module.exports = {
-  id: 'my-local-plugin',
-  preprocessors: {
-    oas3: {
-      "processor-id": () => {
-        // ...
-      }
-    }
-  },
-  decorators: {
-    oas3: {
-      "decorator-id": () => {
-        // ...
-      }
-    }
-  }
-}
+module.exports = function myLocalPlugin() {
+  return {
+    id: 'my-local-plugin',
+    preprocessors: {
+      oas3: {
+        'processor-id': () => {
+          // ...
+        },
+      },
+    },
+    decorators: {
+      oas3: {
+        'decorator-id': () => {
+          // ...
+        },
+      },
+    },
+  };
+};
+
 ```
 
 Each decorator or preprocessor is a function that returns an object. The object's keys are the node types in the document, and each of those can contain any or all of the `enter()`, `leave()` and `skip()` functions for that node type. Find more information and examples on the [visitor pattern page](./visitor.md).
@@ -67,14 +70,16 @@ To use this decorator, add it to a plugin. In this example the main decorator fi
 ```js
 const OperationSparkle = require('./decorators/operation-sparkle.js');
 
-module.exports = {
-  id: 'sparkle',
-  decorators: {
-    oas3: {
-      'operation-sparkle': OperationSparkle,
-    }
-  }
-};
+module.exports =  function sparklePlugin() {
+  return {
+    id: "sparkle",
+    decorators: {
+      oas3: {
+        "operation-sparkle": OperationSparkle,
+      },
+    },
+  };
+}
 ```
 
 The plugin is good to go. For a user to include it in their Redocly configuration, edit the configuration file to look something like this:
@@ -118,15 +123,17 @@ Now extend the decorator from the previous example to add this to the existing p
 const OperationSparkle = require('./decorators/operation-sparkle.js');
 const OpIdSuffix = require('./decorators/add-suffix.js');
 
-module.exports = {
-  id: 'sparkle',
-  decorators: {
-    oas3: {
-      'operation-sparkle': OperationSparkle,
-      'add-opid-suffix': OpIdSuffix,
-    }
-  }
-};
+module.exports = function sparklePlugin() {
+  return {
+    id: "sparkle",
+    decorators: {
+      oas3: {
+        "operation-sparkle": OperationSparkle,
+        "add-opid-suffix": OpIdSuffix,
+      },
+    },
+  };
+}
 ```
 
 All that remains is for a user to configure this decorator in their `redocly.yaml` configuration file to take advantage of the new decorator functionality. Here's an example of the configuration file:
diff --git a/docs/custom-plugins/custom-rules.md b/docs/custom-plugins/custom-rules.md
index af465d21..b6c6b944 100644
--- a/docs/custom-plugins/custom-rules.md
+++ b/docs/custom-plugins/custom-rules.md
@@ -35,18 +35,21 @@ function OperationIdNotTest() {
 
 The `ctx` object here holds all the context, which can be used to give more situation-aware functionality to the rules you build. This is one of the main use cases for custom rules. The `report()` method is used to give information to return to the user if the node being visited doesn't comply with the rule. You can read the [context](#the-context-object) and [location](#location-object) sections for more information.
 
-Adding this as part of a plugin requires you to add it to the `rules` part of the plugin object, under the relevant document type. The example rule here is intended to be used with OpenAPI, so the plugin code in `plugins/my-rules.js` is as follows:
+Adding this as part of a plugin requires you to add it to the `rules` part of the plugin object returned by the exported function, under the relevant document type.
+The example rule here is intended to be used with OpenAPI, so the plugin code in `plugins/my-rules.js` is as follows:
 
 ```js
 const OperationIdNotTest = require('./rules/opid-not-test.js');
 
-module.exports = {
-  id: 'my-rules',
-  rules: {
-    oas3: {
-      'opid-not-test': OperationIdNotTest,
-    }
-  },
+module.exports = function myRulesPlugin() {
+  return {
+    id: 'my-rules',
+    rules: {
+      oas3: {
+        'opid-not-test': OperationIdNotTest,
+      },
+    },
+  };
 };
 ```
 
diff --git a/docs/custom-plugins/extended-types.md b/docs/custom-plugins/extended-types.md
index 43b4d4cb..8594794f 100644
--- a/docs/custom-plugins/extended-types.md
+++ b/docs/custom-plugins/extended-types.md
@@ -38,23 +38,25 @@ Note the quotes around the `owner-team` key since it contains a hyphen `-`. Thes
 To include the new type in the type tree, the plugin must add the type and modify the parent type, which in this example is `info`. This is done by returning a `typeExtension` structure, as shown in the example below (this example is in `plugins/example-type-extension.js`, this filename is used again in the configuration example later):
 
 ```js
-module.exports = {
-  id: 'example-type-extension',
-  typeExtension: {
-    oas3(types) {
-      return {
-        ...types,
-        XMetaData: XMetaData,
-        Info: {
-          ...types.Info,
-          properties: {
-            ...types.Info.properties,
-            'x-metadata': 'XMetaData',
-          }
-        }
-      };
-    }
-  }
+module.exports = function typeExtensionsPlugin() {
+  return {
+    id: 'example-type-extension',
+    typeExtension: {
+      oas3(types) {
+        return {
+          ...types,
+          XMetaData: XMetaData,
+          Info: {
+            ...types.Info,
+            properties: {
+              ...types.Info.properties,
+              'x-metadata': 'XMetaData',
+            },
+          },
+        };
+      },
+    },
+  };
 };
 ```
 
diff --git a/docs/custom-plugins/index.md b/docs/custom-plugins/index.md
index c6333039..779a6e7f 100644
--- a/docs/custom-plugins/index.md
+++ b/docs/custom-plugins/index.md
@@ -43,11 +43,13 @@ The paths are relative to the configuration file location. Where there are multi
 
 ### Plugin structure
 
-The minimal plugin should export an `id` string that is used to refer to the contents of the plugin in the `redocly.yaml` configuration file:
+The minimal plugin should export a function that returns an object with a single property `id` that is used to refer to the contents of the plugin in the `redocly.yaml` configuration file:
 
 ```js
-module.exports = {
-  id: 'my-local-plugin',
+module.exports = function myPlugin() {
+  return {
+    id: 'my-local-plugin',
+  };
 };
 ```
 
diff --git a/docs/guides/change-token-url.md b/docs/guides/change-token-url.md
index 3eb91c22..2c0cd567 100644
--- a/docs/guides/change-token-url.md
+++ b/docs/guides/change-token-url.md
@@ -34,9 +34,11 @@ Estimated time: 15 minutes
      },
    };
 
-   module.exports = {
-     id,
-     decorators,
+   module.exports = function changeTokenPlugin() {
+     return {
+       id,
+       decorators,
+     };
    };
    ```
 
diff --git a/docs/guides/hide-specification-extensions.md b/docs/guides/hide-specification-extensions.md
index 4e81f3e1..d71f82a7 100644
--- a/docs/guides/hide-specification-extensions.md
+++ b/docs/guides/hide-specification-extensions.md
@@ -44,9 +44,11 @@ In this step, create a custom plugin and define the decorator dependency.
      },
    };
 
-   module.exports = {
-     id,
-     decorators,
+   module.exports = function hideExtensionsPlugin() {
+     return {
+       id,
+       decorators,
+     };
    };
    ```
 
diff --git a/docs/guides/replace-servers-url.md b/docs/guides/replace-servers-url.md
index e3c77471..094004ed 100644
--- a/docs/guides/replace-servers-url.md
+++ b/docs/guides/replace-servers-url.md
@@ -84,9 +84,11 @@ const decorators = {
   },
 };
 
-module.exports = {
-  id,
-  decorators,
+module.exports = function replaceServersUrlPlugin() {
+  return {
+    id,
+    decorators,
+  };
 };
 ```
 
diff --git a/docs/rules/configurable-rules.md b/docs/rules/configurable-rules.md
index b0bfba23..b809f293 100644
--- a/docs/rules/configurable-rules.md
+++ b/docs/rules/configurable-rules.md
@@ -242,34 +242,36 @@ rule/operation-summary-check:
 `plugin.js`
 
 ```js
-module.exports = {
-  id: 'local',
-  assertions: {
-    checkWordsStarts: (value, options, ctx) => {
-      const regexp = new RegExp(`^${options.words.join('|')}`);
-      if (regexp.test(value)) {
-        return [];
-      }
-      return [
-        {
-          message: 'Operation summary should start with an active verb',
-          location: ctx.baseLocation,
-        },
-      ];
+module.exports = function localPlugin() {
+  return {
+    id: 'local',
+    assertions: {
+      checkWordsStarts: (value, options, ctx) => {
+        const regexp = new RegExp(`^${options.words.join('|')}`);
+        if (regexp.test(value)) {
+          return [];
+        }
+        return [
+          {
+            message: 'Operation summary should start with an active verb',
+            location: ctx.baseLocation,
+          },
+        ];
+      },
+      checkWordsCount: (value, options, ctx) => {
+        const words = value.split(' ');
+        if (words.length >= options.min) {
+          return [];
+        }
+        return [
+          {
+            message: `Operation summary should contain at least ${options.min} words`,
+            location: ctx.baseLocation,
+          },
+        ];
+      },
     },
-    checkWordsCount: (value, options, ctx) => {
-      const words = value.split(' ');
-      if (words.length >= options.min) {
-        return [];
-      }
-      return [
-        {
-          message: `Operation summary should contain at least ${options.min} words`,
-          location: ctx.baseLocation,
-        },
-      ];
-    },
-  },
+  };
 };
 ```
 
diff --git a/package.json b/package.json
index 8d71944b..b933ab5e 100644
--- a/package.json
+++ b/package.json
@@ -10,13 +10,13 @@
   "engineStrict": true,
   "scripts": {
     "test": "npm run typecheck && npm run compile && npm run unit && npm run e2e",
-    "jest": "REDOCLY_TELEMETRY=off jest ./packages",
+    "jest": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" REDOCLY_TELEMETRY=off jest ./packages",
     "unit": "npm run jest -- --coverage --coverageReporters lcov text-summary",
     "unit:watch": "REDOCLY_TELEMETRY=off jest --watch",
     "coverage:cli": "npm run jest -- --roots packages/cli/src --coverage",
     "coverage:core": "npm run jest -- --roots packages/core/src --coverage",
     "typecheck": "tsc --noEmit --skipLibCheck",
-    "e2e": "npm run webpack-bundle -- --mode=none && REDOCLY_TELEMETRY=off jest --roots=./__tests__/",
+    "e2e": "npm run webpack-bundle -- --mode=none && REDOCLY_TELEMETRY=off NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\"  jest --roots=./__tests__/",
     "prettier": "      npx prettier --write \"**/*.{ts,js,yaml,yml,json,md}\"",
     "prettier:check": "npx prettier --check \"**/*.{ts,js,yaml,yml,json,md}\"",
     "eslint": "eslint packages/**",
diff --git a/packages/core/src/benchmark/benches/lint-with-many-rules.bench.ts b/packages/core/src/benchmark/benches/lint-with-many-rules.bench.ts
index 9e0ddea4..350084bf 100644
--- a/packages/core/src/benchmark/benches/lint-with-many-rules.bench.ts
+++ b/packages/core/src/benchmark/benches/lint-with-many-rules.bench.ts
@@ -4,6 +4,8 @@ import { lintDocument } from '../../lint';
 import { BaseResolver } from '../../resolve';
 import { parseYamlToDocument, makeConfigForRuleset } from '../utils';
 
+import type { StyleguideConfig } from '../../config';
+
 export const name = 'Validate with 50 top-level rules';
 export const count = 10;
 const rebillyDefinitionRef = pathResolve(pathJoin(__dirname, 'rebilly.yaml'));
@@ -25,7 +27,11 @@ for (let i = 0; i < 50; i++) {
   };
 }
 
-const config = makeConfigForRuleset(ruleset);
+let config: StyleguideConfig;
+export async function setupAsync() {
+  config = await makeConfigForRuleset(ruleset);
+}
+
 export function measureAsync() {
   return lintDocument({
     externalRefResolver: new BaseResolver(),
diff --git a/packages/core/src/benchmark/benches/lint-with-nested-rule.bench.ts b/packages/core/src/benchmark/benches/lint-with-nested-rule.bench.ts
index 5656b4af..bb1edb77 100644
--- a/packages/core/src/benchmark/benches/lint-with-nested-rule.bench.ts
+++ b/packages/core/src/benchmark/benches/lint-with-nested-rule.bench.ts
@@ -4,6 +4,8 @@ import { lintDocument } from '../../lint';
 import { BaseResolver } from '../../resolve';
 import { parseYamlToDocument, makeConfigForRuleset } from '../utils';
 
+import type { StyleguideConfig } from '../../config';
+
 export const name = 'Validate with single nested rule';
 export const count = 10;
 const rebillyDefinitionRef = pathResolve(pathJoin(__dirname, 'rebilly.yaml'));
@@ -29,7 +31,11 @@ const visitor = {
     };
   },
 };
-const config = makeConfigForRuleset(visitor);
+let config: StyleguideConfig;
+export async function setupAsync() {
+  config = await makeConfigForRuleset(visitor);
+}
+
 export function measureAsync() {
   return lintDocument({
     externalRefResolver: new BaseResolver(),
diff --git a/packages/core/src/benchmark/benches/lint-with-no-rules.bench.ts b/packages/core/src/benchmark/benches/lint-with-no-rules.bench.ts
index 624c7a4f..b71eded0 100644
--- a/packages/core/src/benchmark/benches/lint-with-no-rules.bench.ts
+++ b/packages/core/src/benchmark/benches/lint-with-no-rules.bench.ts
@@ -3,6 +3,9 @@ import { join as pathJoin, resolve as pathResolve } from 'path';
 import { lintDocument } from '../../lint';
 import { BaseResolver } from '../../resolve';
 import { parseYamlToDocument, makeConfigForRuleset } from '../utils';
+
+import type { StyleguideConfig } from '../../config';
+
 export const name = 'Validate with no rules';
 export const count = 10;
 const rebillyDefinitionRef = pathResolve(pathJoin(__dirname, 'rebilly.yaml'));
@@ -10,7 +13,11 @@ const rebillyDocument = parseYamlToDocument(
   readFileSync(rebillyDefinitionRef, 'utf-8'),
   rebillyDefinitionRef
 );
-const config = makeConfigForRuleset({});
+
+let config: StyleguideConfig;
+export async function setupAsync() {
+  config = await makeConfigForRuleset({});
+}
 export function measureAsync() {
   return lintDocument({
     externalRefResolver: new BaseResolver(),
diff --git a/packages/core/src/benchmark/benches/lint-with-top-level-rule-report.bench.ts b/packages/core/src/benchmark/benches/lint-with-top-level-rule-report.bench.ts
index 605c7422..c8957e7d 100644
--- a/packages/core/src/benchmark/benches/lint-with-top-level-rule-report.bench.ts
+++ b/packages/core/src/benchmark/benches/lint-with-top-level-rule-report.bench.ts
@@ -4,6 +4,8 @@ import { lintDocument } from '../../lint';
 import { BaseResolver } from '../../resolve';
 import { parseYamlToDocument, makeConfigForRuleset } from '../utils';
 
+import type { StyleguideConfig } from '../../config';
+
 export const name = 'Validate with single top-level rule and report';
 export const count = 10;
 const rebillyDefinitionRef = pathResolve(pathJoin(__dirname, 'rebilly.yaml'));
@@ -12,19 +14,22 @@ const rebillyDocument = parseYamlToDocument(
   rebillyDefinitionRef
 );
 
-const config = makeConfigForRuleset({
-  test: () => {
-    return {
-      Schema(schema, ctx) {
-        if (schema.type === 'number') {
-          ctx.report({
-            message: 'type number is not allowed',
-          });
-        }
-      },
-    };
-  },
-});
+let config: StyleguideConfig;
+export async function setupAsync() {
+  config = await makeConfigForRuleset({
+    test: () => {
+      return {
+        Schema(schema, ctx) {
+          if (schema.type === 'number') {
+            ctx.report({
+              message: 'type number is not allowed',
+            });
+          }
+        },
+      };
+    },
+  });
+}
 
 export function measureAsync() {
   return lintDocument({
diff --git a/packages/core/src/benchmark/benches/lint-with-top-level-rule.bench.ts b/packages/core/src/benchmark/benches/lint-with-top-level-rule.bench.ts
index 1d0f8531..a9c7c716 100644
--- a/packages/core/src/benchmark/benches/lint-with-top-level-rule.bench.ts
+++ b/packages/core/src/benchmark/benches/lint-with-top-level-rule.bench.ts
@@ -3,6 +3,9 @@ import { join as pathJoin, resolve as pathResolve } from 'path';
 import { lintDocument } from '../../lint';
 import { BaseResolver } from '../../resolve';
 import { parseYamlToDocument, makeConfigForRuleset } from '../utils';
+
+import type { StyleguideConfig } from '../../config';
+
 export const name = 'Validate with single top-level rule';
 export const count = 10;
 const rebillyDefinitionRef = pathResolve(pathJoin(__dirname, 'rebilly.yaml'));
@@ -11,17 +14,20 @@ const rebillyDocument = parseYamlToDocument(
   rebillyDefinitionRef
 );
 
-const config = makeConfigForRuleset({
-  test: () => {
-    let count = 0;
-    return {
-      Schema() {
-        count++;
-        if (count === -1) throw new Error('Disable optimization');
-      },
-    };
-  },
-});
+let config: StyleguideConfig;
+export async function setupAsync() {
+  config = await makeConfigForRuleset({
+    test: () => {
+      let count = 0;
+      return {
+        Schema() {
+          count++;
+          if (count === -1) throw new Error('Disable optimization');
+        },
+      };
+    },
+  });
+}
 
 export function measureAsync() {
   return lintDocument({
diff --git a/packages/core/src/benchmark/fork.js b/packages/core/src/benchmark/fork.js
index af018b9d..423a92b5 100644
--- a/packages/core/src/benchmark/fork.js
+++ b/packages/core/src/benchmark/fork.js
@@ -26,6 +26,10 @@ if (require.main === module) {
   assert(process.send);
   const module = require(modulePath);
 
+  if (module.setupAsync) {
+    await module.setupAsync();
+  }
+
   if (module.measureAsync) {
     async function run() {
       await clockAsync(7, module.measureAsync); // warm up
diff --git a/packages/core/src/benchmark/utils.ts b/packages/core/src/benchmark/utils.ts
index 283da59d..11b6a8ff 100644
--- a/packages/core/src/benchmark/utils.ts
+++ b/packages/core/src/benchmark/utils.ts
@@ -13,20 +13,20 @@ export function parseYamlToDocument(body: string, absoluteRef: string = ''): Doc
   };
 }
 
-export function makeConfigForRuleset(rules: Oas3RuleSet, plugin?: Partial<Plugin>) {
+export async function makeConfigForRuleset(rules: Oas3RuleSet, plugin?: Partial<Plugin>) {
   const rulesConf: Record<string, RuleConfig> = {};
   const ruleId = 'test';
   Object.keys(rules).forEach((name) => {
     rulesConf[`${ruleId}/${name}`] = 'error';
   });
   const extendConfigs = [
-    resolvePlugins([
+    (await resolvePlugins([
       {
         ...plugin,
         id: ruleId,
         rules: { oas3: rules },
       },
-    ]) as ResolvedStyleguideConfig,
+    ])) as ResolvedStyleguideConfig,
   ];
   if (rules) {
     extendConfigs.push({ rules });
diff --git a/packages/core/src/config/config-resolvers.ts b/packages/core/src/config/config-resolvers.ts
index 68f59b88..d05a5b54 100644
--- a/packages/core/src/config/config-resolvers.ts
+++ b/packages/core/src/config/config-resolvers.ts
@@ -1,4 +1,5 @@
 import * as path from 'path';
+import { existsSync } from 'fs';
 import { isAbsoluteUrl } from '../ref-utils';
 import { pickDefined, isNotString, isString, isDefined, keysOf } from '../utils';
 import { resolveDocument, BaseResolver } from '../resolve';
@@ -6,6 +7,8 @@ import { defaultPlugin } from './builtIn';
 import {
   getResolveConfig,
   getUniquePlugins,
+  isCommonJsPlugin,
+  isDeprecatedPluginFormat,
   mergeExtends,
   parsePresetName,
   prefixRules,
@@ -27,12 +30,18 @@ import type {
   ResolvedStyleguideConfig,
   RuleConfig,
   DeprecatedInRawConfig,
+  ImportedPlugin,
 } from './types';
 import type { Assertion, AssertionDefinition, RawAssertion } from '../rules/common/assertions';
 import type { Asserts, AssertionFn } from '../rules/common/assertions/asserts';
 import type { BundleOptions } from '../bundle';
 import type { Document, ResolvedRefMap } from '../resolve';
 
+const DEFAULT_PROJECT_PLUGIN_PATHS = ['@theme/plugin.js', '@theme/plugin.cjs', '@theme/plugin.mjs'];
+
+// Workaround for dynamic imports being transpiled to require by Typescript: https://github.com/microsoft/TypeScript/issues/43329#issuecomment-811606238
+const _importDynamic = new Function('modulePath', 'return import(modulePath)');
+
 export async function resolveConfigFileAndRefs({
   configPath,
   externalRefResolver = new BaseResolver(),
@@ -101,14 +110,24 @@ export async function resolveConfig({
   );
 }
 
-export function resolvePlugins(
+function getDefaultPluginPath(configPath: string): string | undefined {
+  for (const pluginPath of DEFAULT_PROJECT_PLUGIN_PATHS) {
+    const absolutePluginPath = path.resolve(path.dirname(configPath), pluginPath);
+    if (existsSync(absolutePluginPath)) {
+      return pluginPath;
+    }
+  }
+  return undefined;
+}
+
+export async function resolvePlugins(
   plugins: (string | Plugin)[] | null,
   configPath: string = ''
-): Plugin[] {
+): Promise<Plugin[]> {
   if (!plugins) return [];
 
   // TODO: implement or reuse Resolver approach so it will work in node and browser envs
-  const requireFunc = (plugin: string | Plugin): Plugin | undefined => {
+  const requireFunc = async (plugin: string | Plugin): Promise<ImportedPlugin | undefined> => {
     if (isBrowser && isString(plugin)) {
       logger.error(`Cannot load ${plugin}. Plugins aren't supported in browser yet.`);
 
@@ -117,14 +136,24 @@ export function resolvePlugins(
 
     if (isString(plugin)) {
       try {
-        const absoltePluginPath = path.resolve(path.dirname(configPath), plugin);
+        const maybeAbsolutePluginPath = path.resolve(path.dirname(configPath), plugin);
+
+        const absolutePluginPath = existsSync(maybeAbsolutePluginPath)
+          ? maybeAbsolutePluginPath
+          : // For plugins imported from packages specifically
+            require.resolve(plugin);
+
         // eslint-disable-next-line @typescript-eslint/ban-ts-comment
         // @ts-ignore
-        return typeof __webpack_require__ === 'function'
-          ? // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-            // @ts-ignore
-            __non_webpack_require__(absoltePluginPath)
-          : require(absoltePluginPath);
+        if (typeof __webpack_require__ === 'function') {
+          // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+          // @ts-ignore
+          return __non_webpack_require__(absolutePluginPath);
+        } else {
+          // you can import both cjs and mjs
+          const mod = await _importDynamic(absolutePluginPath);
+          return mod.default || mod;
+        }
       } catch (e) {
         if (e instanceof SyntaxError) {
           throw e;
@@ -138,19 +167,47 @@ export function resolvePlugins(
 
   const seenPluginIds = new Map<string, string>();
 
-  return plugins
-    .map((p) => {
-      if (isString(p) && isAbsoluteUrl(p)) {
-        throw new Error(colorize.red(`We don't support remote plugins yet.`));
+  /**
+   * Include the default plugin automatically if it's not in configuration
+   */
+  const defaultPluginPath = getDefaultPluginPath(configPath);
+  if (defaultPluginPath) {
+    plugins.push(defaultPluginPath);
+  }
+
+  const resolvedPlugins: Set<string> = new Set();
+
+  const instances = await Promise.all(
+    plugins.map(async (p) => {
+      if (isString(p)) {
+        if (isAbsoluteUrl(p)) {
+          throw new Error(colorize.red(`We don't support remote plugins yet.`));
+        }
+        if (resolvedPlugins.has(p)) {
+          return;
+        }
+
+        resolvedPlugins.add(p);
       }
 
-      // TODO: resolve npm packages similar to eslint
-      const pluginModule = requireFunc(p);
+      const requiredPlugin: ImportedPlugin | undefined = await requireFunc(p);
+
+      const pluginCreatorOptions = { contentDir: path.dirname(configPath) };
+
+      const pluginModule = isDeprecatedPluginFormat(requiredPlugin)
+        ? requiredPlugin
+        : isCommonJsPlugin(requiredPlugin)
+        ? await requiredPlugin(pluginCreatorOptions)
+        : await requiredPlugin?.default?.(pluginCreatorOptions);
 
       if (!pluginModule) {
         return;
       }
 
+      if (isString(p) && pluginModule.id && isDeprecatedPluginFormat(requiredPlugin)) {
+        logger.info(`Deprecated plugin format detected: ${pluginModule.id}\n`);
+      }
+
       const id = pluginModule.id;
       if (typeof id !== 'string') {
         throw new Error(
@@ -266,7 +323,9 @@ export function resolvePlugins(
 
       return plugin;
     })
-    .filter(isDefined);
+  );
+
+  return instances.filter(isDefined);
 }
 
 export async function resolveApis({
@@ -317,7 +376,7 @@ async function resolveAndMergeNestedStyleguideConfig(
     throw new Error(`Circular dependency in config file: "${configPath}"`);
   }
   const plugins = getUniquePlugins(
-    resolvePlugins([...(styleguideConfig?.plugins || []), defaultPlugin], configPath)
+    await resolvePlugins([...(styleguideConfig?.plugins || []), defaultPlugin], configPath)
   );
   const pluginPaths = styleguideConfig?.plugins
     ?.filter(isString)
diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts
index ace6daae..10c2a187 100644
--- a/packages/core/src/config/types.ts
+++ b/packages/core/src/config/types.ts
@@ -144,6 +144,22 @@ export type Plugin = {
   assertions?: AssertionsConfig;
 };
 
+type PluginCreatorOptions = {
+  contentDir: string;
+};
+
+export type PluginCreator = (options: PluginCreatorOptions) => Plugin | Promise<Plugin>;
+
+export type ImportedPlugin =
+  // ES Modules
+  | {
+      default?: PluginCreator;
+    }
+  // CommonJS
+  | PluginCreator
+  // Deprecated format
+  | Plugin;
+
 export type PluginStyleguideConfig<T = undefined> = Omit<
   StyleguideRawConfig<T>,
   'plugins' | 'extends'
diff --git a/packages/core/src/config/utils.ts b/packages/core/src/config/utils.ts
index ed8e2f41..92904d4e 100644
--- a/packages/core/src/config/utils.ts
+++ b/packages/core/src/config/utils.ts
@@ -12,9 +12,9 @@ import type {
   Api,
   DeprecatedInApi,
   DeprecatedInRawConfig,
+  ImportedPlugin,
   FlatApi,
   FlatRawConfig,
-  Plugin,
   RawConfig,
   RawResolveConfig,
   ResolveConfig,
@@ -22,6 +22,8 @@ import type {
   RulesFields,
   StyleguideRawConfig,
   ThemeConfig,
+  Plugin,
+  PluginCreator,
 } from './types';
 
 export function parsePresetName(presetName: string): { pluginId: string; configName: string } {
@@ -399,3 +401,11 @@ export class ConfigValidationError extends Error {}
 export function deepCloneMapWithJSON<K, V>(originalMap: Map<K, V>): Map<K, V> {
   return new Map(JSON.parse(JSON.stringify([...originalMap])));
 }
+
+export function isDeprecatedPluginFormat(plugin: ImportedPlugin | undefined): plugin is Plugin {
+  return plugin !== undefined && typeof plugin === 'object' && 'id' in plugin;
+}
+
+export function isCommonJsPlugin(plugin: ImportedPlugin | undefined): plugin is PluginCreator {
+  return typeof plugin === 'function';
+}


Test patch:
diff --git a/__tests__/bundle/resolve-refs-from-x-attributes/snapshot.js b/__tests__/bundle/resolve-refs-from-x-attributes/snapshot.js
index 0e21318e..5708e77a 100644
--- a/__tests__/bundle/resolve-refs-from-x-attributes/snapshot.js
+++ b/__tests__/bundle/resolve-refs-from-x-attributes/snapshot.js
@@ -18,6 +18,8 @@ info:
         name: test 2
 components: {}
 
+Deprecated plugin format detected: plugin
+Deprecated plugin format detected: plugin
 bundling ./openapi.yaml...
 📦 Created a bundle for ./openapi.yaml at stdout <test>ms.
 
diff --git a/__tests__/check-config/config-type-extensions-in-assertions/plugins/type-extention.js b/__tests__/check-config/config-type-extensions-in-assertions/plugins/type-extention.js
index 7dca3c6a..688075b9 100644
--- a/__tests__/check-config/config-type-extensions-in-assertions/plugins/type-extention.js
+++ b/__tests__/check-config/config-type-extensions-in-assertions/plugins/type-extention.js
@@ -6,22 +6,24 @@ const XMetaData = {
   required: ['lifecycle'],
 };
 
-module.exports = {
-  id: 'type-extension',
-  typeExtension: {
-    oas3(types) {
-      newTypes = {
-        ...types,
-        XMetaData: XMetaData,
-        Info: {
-          ...types.Info,
-          properties: {
-            ...types.Info.properties,
-            'x-metadata': 'XMetaData',
+module.exports = function typeExtensionPlugin() {
+  return {
+    id: 'type-extension',
+    typeExtension: {
+      oas3(types) {
+        newTypes = {
+          ...types,
+          XMetaData: XMetaData,
+          Info: {
+            ...types.Info,
+            properties: {
+              ...types.Info.properties,
+              'x-metadata': 'XMetaData',
+            },
           },
-        },
-      };
-      return newTypes;
+        };
+        return newTypes;
+      },
     },
-  },
+  };
 };
diff --git a/__tests__/check-config/wrong-config-type-extensions-in-assertions/snapshot.js b/__tests__/check-config/wrong-config-type-extensions-in-assertions/snapshot.js
index 4a116dc5..fe75e0df 100644
--- a/__tests__/check-config/wrong-config-type-extensions-in-assertions/snapshot.js
+++ b/__tests__/check-config/wrong-config-type-extensions-in-assertions/snapshot.js
@@ -2,6 +2,7 @@
 
 exports[`E2E check-config wrong config type extension in assertions 1`] = `
 
+Deprecated plugin format detected: type-extension
 [1] redocly.yaml:10:13 at #/rules/rule~1metadata-lifecycle/subject/type
 
 \`type\` can be one of the following only: "any", "Root", "Tag", "TagList", "TagGroups", "TagGroup", "ExternalDocs", "Example", "ExamplesMap", "EnumDescriptions", "SecurityRequirement", "SecurityRequirementList", "Info", "Contact", "License", "Logo", "Paths", "PathItem", "Parameter", "ParameterItems", "ParameterList", "Operation", "Examples", "Header", "Responses", "Response", "Schema", "Xml", "SchemaProperties", "NamedSchemas", "NamedResponses", "NamedParameters", "NamedSecuritySchemes", "SecurityScheme", "XCodeSample", "XCodeSampleList", "XServerList", "XServer", "Server", "ServerList", "ServerVariable", "ServerVariablesMap", "Callback", "CallbacksMap", "RequestBody", "MediaTypesMap", "MediaType", "Encoding", "EncodingMap", "HeadersMap", "Link", "DiscriminatorMapping", "Discriminator", "Components", "LinksMap", "NamedExamples", "NamedRequestBodies", "NamedHeaders", "NamedLinks", "NamedCallbacks", "ImplicitFlow", "PasswordFlow", "ClientCredentials", "AuthorizationCode", "OAuth2Flows", "XUsePkce", "WebhooksMap", "XMetaData", "PatternProperties", "NamedPathItems", "DependentRequired", "HttpServerBinding", "HttpChannelBinding", "HttpMessageBinding", "HttpOperationBinding", "WsServerBinding", "WsChannelBinding", "WsMessageBinding", "WsOperationBinding", "KafkaServerBinding", "KafkaTopicConfiguration", "KafkaChannelBinding", "KafkaMessageBinding", "KafkaOperationBinding", "AnypointmqServerBinding", "AnypointmqChannelBinding", "AnypointmqMessageBinding", "AnypointmqOperationBinding", "AmqpServerBinding", "AmqpChannelBinding", "AmqpMessageBinding", "AmqpOperationBinding", "Amqp1ServerBinding", "Amqp1ChannelBinding", "Amqp1MessageBinding", "Amqp1OperationBinding", "MqttServerBindingLastWill", "MqttServerBinding", "MqttChannelBinding", "MqttMessageBinding", "MqttOperationBinding", "Mqtt5ServerBinding", "Mqtt5ChannelBinding", "Mqtt5MessageBinding", "Mqtt5OperationBinding", "NatsServerBinding", "NatsChannelBinding", "NatsMessageBinding", "NatsOperationBinding", "JmsServerBinding", "JmsChannelBinding", "JmsMessageBinding", "JmsOperationBinding", "SolaceServerBinding", "SolaceChannelBinding", "SolaceMessageBinding", "SolaceDestination", "SolaceOperationBinding", "StompServerBinding", "StompChannelBinding", "StompMessageBinding", "StompOperationBinding", "RedisServerBinding", "RedisChannelBinding", "RedisMessageBinding", "RedisOperationBinding", "MercureServerBinding", "MercureChannelBinding", "MercureMessageBinding", "MercureOperationBinding", "ServerBindings", "ChannelBindings", "MessageBindings", "OperationBindings", "ServerMap", "ChannelMap", "Channel", "ParametersMap", "MessageExample", "NamedMessages", "NamedMessageTraits", "NamedOperationTraits", "NamedCorrelationIds", "SecuritySchemeFlows", "Message", "OperationTrait", "OperationTraitList", "MessageTrait", "MessageTraitList", "MessageExampleList", "CorrelationId", "Dependencies", "OperationReply", "OperationReplyAddress", "NamedTags", "NamedExternalDocs", "NamedChannels", "NamedOperations", "NamedOperationReplies", "NamedOperationRelyAddresses", "SecuritySchemeList", "MessageList", "SourceDescriptions", "OpenAPISourceDescription", "NoneSourceDescription", "ArazzoSourceDescription", "Parameters", "ReusableObject", "Workflows", "Workflow", "Steps", "Step", "Replacement", "ExtendedOperation", "ExpectSchema", "Outputs", "CriterionObject", "XPathCriterion", "JSONPathCriterion", "SuccessActionObject", "OnSuccessActionList", "FailureActionObject", "OnFailureActionList", "NamedInputs", "NamedSuccessActions", "NamedFailureActions", "SpecExtension".
diff --git a/__tests__/commands.test.ts b/__tests__/commands.test.ts
index ad83820e..1f6e793f 100644
--- a/__tests__/commands.test.ts
+++ b/__tests__/commands.test.ts
@@ -558,6 +558,26 @@ describe('E2E', () => {
       const result = getCommandOutput(args, testPath);
       (<any>expect(cleanupOutput(result))).toMatchSpecificSnapshot(join(testPath, 'snapshot.js'));
     });
+
+    test('lint with a rule from a plugin', () => {
+      const testPath = join(folderPath, 'resolve-plugins');
+      const args = getParams('../../../packages/cli/src/index.ts', 'lint', [
+        'openapi.yaml',
+        '--config=plugin-config.yaml',
+      ]);
+      const result = getCommandOutput(args, testPath);
+      (<any>expect(cleanupOutput(result))).toMatchSpecificSnapshot(join(testPath, 'snapshot.js'));
+    });
+
+    test('decorate with a decorator from a plugin', () => {
+      const testPath = join(folderPath, 'resolve-plugins');
+      const args = getParams('../../../packages/cli/src/index.ts', 'bundle', [
+        'openapi.yaml',
+        '--config=plugin-config.yaml',
+      ]);
+      const result = getCommandOutput(args, testPath);
+      (<any>expect(cleanupOutput(result))).toMatchSpecificSnapshot(join(testPath, 'snapshot.js'));
+    });
   });
 
   describe('build-docs', () => {
diff --git a/__tests__/lint/arazzo-type-extensions-with-plugin/snapshot.js b/__tests__/lint/arazzo-type-extensions-with-plugin/snapshot.js
index 51b2066a..8c43799e 100644
--- a/__tests__/lint/arazzo-type-extensions-with-plugin/snapshot.js
+++ b/__tests__/lint/arazzo-type-extensions-with-plugin/snapshot.js
@@ -2,6 +2,7 @@
 
 exports[`E2E lint arazzo-type-extensions-with-plugin 1`] = `
 
+Deprecated plugin format detected: type-extension
 validating museum.yaml...
 [1] museum.yaml:8:3 at #/info/wrong-key
 
diff --git a/__tests__/lint/assertion-string-property-min-length/plugin.js b/__tests__/lint/assertion-string-property-min-length/plugin.js
deleted file mode 100644
index cc56c5cb..00000000
--- a/__tests__/lint/assertion-string-property-min-length/plugin.js
+++ /dev/null
@@ -1,28 +0,0 @@
-module.exports = {
-  id: 'local',
-  assertions: {
-    checkSchema: (_, opts, ctx) => {
-      const name = opts.required;
-
-      const rawValue = ctx.rawValue;
-      const errors = [];
-
-      if (Array.isArray(rawValue?.required) && rawValue?.properties) {
-        const required = rawValue?.required;
-        const properties = rawValue?.properties;
-
-        for (const item of required) {
-          if (properties[item] && properties[item].type === 'string') {
-            if (!properties[item][name]) {
-              errors.push({
-                message: `Required property ${name} inside ${item} string property`,
-                location: ctx.baseLocation,
-              });
-            }
-          }
-        }
-      }
-      return errors;
-    },
-  },
-};
diff --git a/__tests__/lint/assertion-string-property-min-length/plugin.mjs b/__tests__/lint/assertion-string-property-min-length/plugin.mjs
new file mode 100644
index 00000000..d62c0881
--- /dev/null
+++ b/__tests__/lint/assertion-string-property-min-length/plugin.mjs
@@ -0,0 +1,30 @@
+export default function plugin() {
+  return {
+    id: 'local',
+    assertions: {
+      checkSchema: (_, opts, ctx) => {
+        const name = opts.required;
+
+        const rawValue = ctx.rawValue;
+        const errors = [];
+
+        if (Array.isArray(rawValue?.required) && rawValue?.properties) {
+          const required = rawValue?.required;
+          const properties = rawValue?.properties;
+
+          for (const item of required) {
+            if (properties[item] && properties[item].type === 'string') {
+              if (!properties[item][name]) {
+                errors.push({
+                  message: `Required property ${name} inside ${item} string property`,
+                  location: ctx.baseLocation,
+                });
+              }
+            }
+          }
+        }
+        return errors;
+      },
+    },
+  };
+}
diff --git a/__tests__/lint/assertion-string-property-min-length/redocly.yaml b/__tests__/lint/assertion-string-property-min-length/redocly.yaml
index d01ed682..0b447243 100644
--- a/__tests__/lint/assertion-string-property-min-length/redocly.yaml
+++ b/__tests__/lint/assertion-string-property-min-length/redocly.yaml
@@ -3,7 +3,7 @@ apis:
     root: ./openapi.yaml
 
 plugins:
-  - ./plugin.js
+  - ./plugin.mjs
 rules:
   rule/minLength:
     subject:
diff --git a/__tests__/lint/assertions-custom-function-options/plugin.js b/__tests__/lint/assertions-custom-function-options/plugin.js
deleted file mode 100644
index c287bab6..00000000
--- a/__tests__/lint/assertions-custom-function-options/plugin.js
+++ /dev/null
@@ -1,29 +0,0 @@
-module.exports = {
-  id: 'local',
-  assertions: {
-    checkWordsStarts: (value, opts, ctx) => {
-      const regexp = new RegExp(`^${opts.words.join('|')}`);
-      if (regexp.test(value)) {
-        return [];
-      }
-      return [
-        {
-          message: `Should start with one of ${opts.words.join(', ')}`,
-          location: ctx.baseLocation,
-        },
-      ];
-    },
-    checkWordsCount: (value, opts, ctx) => {
-      const words = value.split(' ');
-      if (words.length >= opts.min) {
-        return [];
-      }
-      return [
-        {
-          message: `Should have at least ${opts.min} words`,
-          location: ctx.baseLocation,
-        },
-      ];
-    },
-  },
-};
diff --git a/__tests__/lint/assertions-custom-function-options/plugin.mjs b/__tests__/lint/assertions-custom-function-options/plugin.mjs
new file mode 100644
index 00000000..6518c1de
--- /dev/null
+++ b/__tests__/lint/assertions-custom-function-options/plugin.mjs
@@ -0,0 +1,31 @@
+export default async function plugin() {
+  return {
+    id: 'local',
+    assertions: {
+      checkWordsStarts: (value, opts, ctx) => {
+        const regexp = new RegExp(`^${opts.words.join('|')}`);
+        if (regexp.test(value)) {
+          return [];
+        }
+        return [
+          {
+            message: `Should start with one of ${opts.words.join(', ')}`,
+            location: ctx.baseLocation,
+          },
+        ];
+      },
+      checkWordsCount: (value, opts, ctx) => {
+        const words = value.split(' ');
+        if (words.length >= opts.min) {
+          return [];
+        }
+        return [
+          {
+            message: `Should have at least ${opts.min} words`,
+            location: ctx.baseLocation,
+          },
+        ];
+      },
+    },
+  };
+}
diff --git a/__tests__/lint/assertions-custom-function-options/redocly.yaml b/__tests__/lint/assertions-custom-function-options/redocly.yaml
index 10572c64..292c1d83 100644
--- a/__tests__/lint/assertions-custom-function-options/redocly.yaml
+++ b/__tests__/lint/assertions-custom-function-options/redocly.yaml
@@ -2,7 +2,7 @@ apis:
   main:
     root: ./openapi.yaml
 plugins:
-  - ./plugin.js
+  - ./plugin.mjs
 rules:
   rule/operation-summary-length-custom:
     subject:
diff --git a/__tests__/miscellaneous/resolve-plugins/openapi.yaml b/__tests__/miscellaneous/resolve-plugins/openapi.yaml
new file mode 100644
index 00000000..882e62e1
--- /dev/null
+++ b/__tests__/miscellaneous/resolve-plugins/openapi.yaml
@@ -0,0 +1,9 @@
+openapi: 3.1.0
+info:
+  title: Test
+paths: {}
+components:
+  securitySchemes:
+    OpenID:
+      type: openIdConnect
+      openIdConnectUrl: https://example.com/missing-well-known-configuration
diff --git a/__tests__/miscellaneous/resolve-plugins/plugin-config.yaml b/__tests__/miscellaneous/resolve-plugins/plugin-config.yaml
new file mode 100644
index 00000000..d36bb4b3
--- /dev/null
+++ b/__tests__/miscellaneous/resolve-plugins/plugin-config.yaml
@@ -0,0 +1,6 @@
+plugins:
+  - '../../../packages/core/src/config/__tests__/fixtures/plugin.js'
+decorators:
+  test-plugin/inject-x-stats: on
+rules:
+  test-plugin/openid-connect-url-well-known: error
diff --git a/__tests__/miscellaneous/resolve-plugins/snapshot.js b/__tests__/miscellaneous/resolve-plugins/snapshot.js
new file mode 100644
index 00000000..31694c48
--- /dev/null
+++ b/__tests__/miscellaneous/resolve-plugins/snapshot.js
@@ -0,0 +1,45 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`E2E miscellaneous decorate with a decorator from a plugin 1`] = `
+openapi: 3.1.0
+info:
+  title: Test
+  x-stats:
+    test: 1
+paths: {}
+components:
+  securitySchemes:
+    OpenID:
+      type: openIdConnect
+      openIdConnectUrl: https://example.com/missing-well-known-configuration
+
+Deprecated plugin format detected: test-plugin
+bundling openapi.yaml...
+📦 Created a bundle for openapi.yaml at stdout <test>ms.
+
+`;
+
+exports[`E2E miscellaneous lint with a rule from a plugin 1`] = `
+
+Deprecated plugin format detected: test-plugin
+validating openapi.yaml...
+[1] openapi.yaml:9:25 at #/components/securitySchemes/OpenID/openIdConnectUrl
+
+openIdConnectUrl must be a URL that ends with /.well-known/openid-configuration
+
+ 7 | OpenID:
+ 8 |   type: openIdConnect
+ 9 |   openIdConnectUrl: https://example.com/missing-well-known-configuration
+   |                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+10 |
+
+Error was generated by the test-plugin/openid-connect-url-well-known rule.
+
+
+openapi.yaml: validated in <test>ms
+
+❌ Validation failed with 1 error.
+run \`redocly lint --generate-ignore-file\` to add all problems to the ignore file.
+
+
+`;
diff --git a/__tests__/miscellaneous/resolve-refs-in-preprocessors/plugin.js b/__tests__/miscellaneous/resolve-refs-in-preprocessors/plugin.js
index f6fcced3..25e90e48 100644
--- a/__tests__/miscellaneous/resolve-refs-in-preprocessors/plugin.js
+++ b/__tests__/miscellaneous/resolve-refs-in-preprocessors/plugin.js
@@ -17,7 +17,9 @@ const preprocessors = {
   },
 };
 
-module.exports = {
-  id: 'plugin',
-  preprocessors,
+module.exports = function plugin() {
+  return {
+    id: 'plugin',
+    preprocessors,
+  };
 };
diff --git a/packages/core/__tests__/utils.ts b/packages/core/__tests__/utils.ts
index 69b3ef4b..1e25e091 100644
--- a/packages/core/__tests__/utils.ts
+++ b/packages/core/__tests__/utils.ts
@@ -45,7 +45,7 @@ export const yamlSerializer = {
   },
 };
 
-export function makeConfigForRuleset(
+export async function makeConfigForRuleset(
   rules: Oas3RuleSet,
   plugin?: Partial<Plugin>,
   version: string = 'oas3'
@@ -55,7 +55,7 @@ export function makeConfigForRuleset(
   Object.keys(rules).forEach((name) => {
     rulesConf[`${ruleId}/${name}`] = 'error';
   });
-  const plugins = resolvePlugins([
+  const plugins = await resolvePlugins([
     {
       ...plugin,
       id: ruleId,
diff --git a/packages/core/src/__tests__/walk.test.ts b/packages/core/src/__tests__/walk.test.ts
index 778e086f..8f2f17ed 100644
--- a/packages/core/src/__tests__/walk.test.ts
+++ b/packages/core/src/__tests__/walk.test.ts
@@ -53,7 +53,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(testRuleSet.test).toBeCalledTimes(1);
@@ -100,12 +100,12 @@ describe('walk order', () => {
     const document = parseYamlToDocument(
       outdent`
         openapi: 3.0.0
-        servers: 
+        servers:
         - url: http://{test}.url
           variables:
             test: test
-        paths: 
-          /test-path: 
+        paths:
+          /test-path:
             get:
               responses:
                 200:
@@ -121,7 +121,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(testRuleSet.test).toBeCalledTimes(1);
@@ -184,7 +184,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(calls).toMatchInlineSnapshot(`
@@ -264,7 +264,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet, undefined, 'oas2'),
+      config: await makeConfigForRuleset(testRuleSet, undefined, 'oas2'),
     });
 
     expect(calls).toMatchInlineSnapshot(`
@@ -345,7 +345,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(calls).toMatchInlineSnapshot(`
@@ -417,7 +417,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(calls).toMatchInlineSnapshot(`
@@ -489,7 +489,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(calls).toMatchInlineSnapshot(`
@@ -556,7 +556,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(calls).toMatchInlineSnapshot(`
@@ -610,7 +610,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(calls).toMatchInlineSnapshot(`
@@ -666,7 +666,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(calls).toMatchInlineSnapshot(`
@@ -708,7 +708,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(calls).toMatchInlineSnapshot(`
@@ -763,7 +763,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(calls).toMatchInlineSnapshot(`
@@ -849,7 +849,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(calls).toMatchInlineSnapshot(`
@@ -928,7 +928,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(calls).toMatchInlineSnapshot(`
@@ -1000,7 +1000,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(calls).toMatchInlineSnapshot(`
@@ -1044,7 +1044,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(calls).toMatchInlineSnapshot(`
@@ -1109,7 +1109,7 @@ describe('walk order', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(calls).toMatchInlineSnapshot(`
@@ -1197,7 +1197,7 @@ describe('context.report', () => {
     const results = await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(results).toHaveLength(3);
@@ -1277,7 +1277,7 @@ describe('context.report', () => {
     const results = await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(results).toHaveLength(4);
@@ -1385,7 +1385,7 @@ describe('context.resolve', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
   });
 });
@@ -1434,7 +1434,7 @@ describe('type extensions', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet, {
+      config: await makeConfigForRuleset(testRuleSet, {
         typeExtension: {
           oas3(types, version) {
             expect(version).toEqual(oas);
@@ -1528,7 +1528,7 @@ describe('ignoreNextRules', () => {
     await lintDocument({
       externalRefResolver: new BaseResolver(),
       document,
-      config: makeConfigForRuleset(testRuleSet),
+      config: await makeConfigForRuleset(testRuleSet),
     });
 
     expect(calls).toMatchInlineSnapshot(`
diff --git a/packages/core/src/config/__tests__/config-resolvers.test.ts b/packages/core/src/config/__tests__/config-resolvers.test.ts
index 6ece8a00..2b4bdd78 100644
--- a/packages/core/src/config/__tests__/config-resolvers.test.ts
+++ b/packages/core/src/config/__tests__/config-resolvers.test.ts
@@ -91,6 +91,88 @@ describe('resolveStyleguideConfig', () => {
     });
   });
 
+  it('should resolve local file config with esm plugin', async () => {
+    const config = {
+      ...baseStyleguideConfig,
+      extends: ['local-config-with-esm.yaml'],
+    };
+
+    const { plugins, ...styleguide } = await resolveStyleguideConfig({
+      styleguideConfig: config,
+      configPath,
+    });
+
+    expect(styleguide?.rules?.['operation-2xx-response']).toEqual('warn');
+    expect(plugins).toBeDefined();
+    expect(plugins?.length).toBe(2);
+
+    const localPlugin = plugins?.find((p) => p.id === 'test-plugin');
+    expect(localPlugin).toBeDefined();
+
+    expect(localPlugin).toMatchObject({
+      id: 'test-plugin',
+      rules: {
+        oas3: {
+          'test-plugin/oas3-rule-name': 'oas3-rule-stub',
+        },
+      },
+    });
+
+    expect(styleguide.extendPaths!.map(removeAbsolutePath)).toEqual([
+      'resolve-config/redocly.yaml',
+      'resolve-config/local-config-with-esm.yaml',
+      'resolve-config/redocly.yaml',
+    ]);
+    expect(styleguide.pluginPaths!.map(removeAbsolutePath)).toEqual([
+      'resolve-config/plugin-esm.mjs',
+    ]);
+
+    expect(styleguide.rules).toEqual({
+      'operation-2xx-response': 'warn',
+    });
+  });
+
+  it('should resolve local file config with commonjs plugin with a default export function', async () => {
+    const config = {
+      ...baseStyleguideConfig,
+      extends: ['local-config-with-commonjs-export-function.yaml'],
+    };
+
+    const { plugins, ...styleguide } = await resolveStyleguideConfig({
+      styleguideConfig: config,
+      configPath,
+    });
+
+    expect(styleguide?.rules?.['operation-2xx-response']).toEqual('warn');
+    expect(plugins).toBeDefined();
+    expect(plugins?.length).toBe(2);
+
+    const localPlugin = plugins?.find((p) => p.id === 'test-plugin');
+    expect(localPlugin).toBeDefined();
+
+    expect(localPlugin).toMatchObject({
+      id: 'test-plugin',
+      rules: {
+        oas3: {
+          'test-plugin/oas3-rule-name': 'oas3-rule-stub',
+        },
+      },
+    });
+
+    expect(styleguide.extendPaths!.map(removeAbsolutePath)).toEqual([
+      'resolve-config/redocly.yaml',
+      'resolve-config/local-config-with-commonjs-export-function.yaml',
+      'resolve-config/redocly.yaml',
+    ]);
+    expect(styleguide.pluginPaths!.map(removeAbsolutePath)).toEqual([
+      'resolve-config/plugin-with-export-function.cjs',
+    ]);
+
+    expect(styleguide.rules).toEqual({
+      'operation-2xx-response': 'warn',
+    });
+  });
+
   // TODO: fix circular test
   it.skip('should throw circular error', () => {
     const config = {
diff --git a/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-commonjs-export-function.yaml b/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-commonjs-export-function.yaml
new file mode 100644
index 00000000..9d892584
--- /dev/null
+++ b/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-commonjs-export-function.yaml
@@ -0,0 +1,2 @@
+plugins:
+  - plugin-with-export-function.cjs
diff --git a/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-esm.yaml b/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-esm.yaml
new file mode 100644
index 00000000..59a3d933
--- /dev/null
+++ b/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-esm.yaml
@@ -0,0 +1,2 @@
+plugins:
+  - plugin-esm.mjs
diff --git a/packages/core/src/config/__tests__/fixtures/resolve-config/plugin-esm.mjs b/packages/core/src/config/__tests__/fixtures/resolve-config/plugin-esm.mjs
new file mode 100644
index 00000000..66505a25
--- /dev/null
+++ b/packages/core/src/config/__tests__/fixtures/resolve-config/plugin-esm.mjs
@@ -0,0 +1,10 @@
+export default async function () {
+  return {
+    id: 'test-plugin',
+    rules: {
+      oas3: {
+        'oas3-rule-name': 'oas3-rule-stub',
+      },
+    },
+  };
+}
diff --git a/packages/core/src/config/__tests__/fixtures/resolve-config/plugin-with-export-function.cjs b/packages/core/src/config/__tests__/fixtures/resolve-config/plugin-with-export-function.cjs
new file mode 100644
index 00000000..474c7e69
--- /dev/null
+++ b/packages/core/src/config/__tests__/fixtures/resolve-config/plugin-with-export-function.cjs
@@ -0,0 +1,10 @@
+module.exports = async function () {
+  return {
+    id: 'test-plugin',
+    rules: {
+      oas3: {
+        'oas3-rule-name': 'oas3-rule-stub',
+      },
+    },
+  };
+};
diff --git a/packages/core/src/config/__tests__/resolve-plugins.test.ts b/packages/core/src/config/__tests__/resolve-plugins.test.ts
index badbf3f9..17ae5009 100644
--- a/packages/core/src/config/__tests__/resolve-plugins.test.ts
+++ b/packages/core/src/config/__tests__/resolve-plugins.test.ts
@@ -1,7 +1,10 @@
 import * as path from 'path';
 import { loadConfig } from '../load';
 
-describe('resolving a plugin', () => {
+// FIXME: skipping due to the 'Test environment has been torn down' Jest error.
+// Covered the below in the `miscellaneous/resolve-plugins` e2e tests.
+// Unskip when Jest gets this fixed.
+describe.skip('resolving a plugin', () => {
   const configPath = path.join(__dirname, 'fixtures/plugin-config.yaml');
 
   it('should prefix rule names with the plugin id', async () => {
diff --git a/packages/core/src/rules/oas3/__tests__/spec/spec.test.ts b/packages/core/src/rules/oas3/__tests__/spec/spec.test.ts
index e41908c4..6be1ab0c 100644
--- a/packages/core/src/rules/oas3/__tests__/spec/spec.test.ts
+++ b/packages/core/src/rules/oas3/__tests__/spec/spec.test.ts
@@ -5,10 +5,19 @@ import { StyleguideConfig, defaultPlugin, resolvePlugins, resolvePreset } from '
 
 import { BaseResolver } from '../../../../resolve';
 
-const plugins = resolvePlugins([defaultPlugin]);
-const pressets = resolvePreset('all', plugins);
-const allConfig = new StyleguideConfig({ ...pressets, plugins });
+import type { Plugin, ResolvedStyleguideConfig } from '../../../../config';
+
 describe('Oas3 Structural visitor basic', () => {
+  let plugins: Plugin[];
+  let presets: ResolvedStyleguideConfig;
+  let allConfig: StyleguideConfig;
+
+  beforeAll(async () => {
+    plugins = await resolvePlugins([defaultPlugin]);
+    presets = resolvePreset('all', plugins);
+    allConfig = new StyleguideConfig({ ...presets, plugins });
+  });
+
   it('should report wrong types', async () => {
     const document = parseYamlToDocument(
       outdent`