Repository: Redocly/redocly-cli
Pull request #1345 (2023-12-01 12:48:16)

feat: resolve refs in config
## What/Why/How?

This allows using $ref in config files like this:

```yaml
# redocly.yaml
seo:
  $ref: seo.yaml
rbac: 
  $ref: rbac.yaml
```

⚠️  When using the `push` command with $ref's in config file, user should explicitly upload all referenced files in the config using `--files` option.

- [x] Resolve $refs in config files
- [x] Add basic linting support
- [x] Extend config node types to be linted
- [x] Add basic support for VS Code extension
- [x] Check compatibility with other tools (scout and portal)
- [ ] ~Look into https://github.com/Redocly/redoc/issues/740#issuecomment-1670740699~ (is not related; better to be handled in a separate MR)

Also fixed a couple of issues with resolving refs and formatting the output.



## Reference

Resolves https://github.com/Redocly/redocly-cli/issues/1219

## Testing

## Screenshots (optional)

## Check yourself

- [x] Code is linted
- [x] Tested with redoc/reference-docs/workflows (internal)
- [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


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

Latest commit: a893d88950ab38f78cc455e08e6e13937069a05e

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/test/refs-in-config?filename=.changeset/angry-starfishes-fly.md&value=---%0A%22%40redocly%2Fcli%22%3A%20patch%0A%22%40redocly%2Fopenapi-core%22%3A%20patch%0A---%0A%0Afeat%3A%20resolve%20refs%20in%20config%0A)


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 | 76.3%      |    4093/5364    |
|                                                                           🟡                                                                           | Branches   | 66.25%     |    2179/3289    |
|                                                                           🟡                                                                           | Functions  | 68.52%     |     664/969     |
|                                                                           🟡                                                                           | Lines      | 76.51%     |    3840/5019    |


<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                                                         |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------- | :------------------------------------------------------------ | :------- | :------------------------------------------------------------- | :------------------------------------------------------------ |
|                                                                           🟡                                                                           | core/src/utils.ts  | 77.6%                                                         | 63.64%   | 77.27%                                                         | 77.59%                                                        |
|                                                                           🟡                                                                           | core/src/bundle.ts | <div title="Base coverage is: 76.6%">77.99% (+1.39% 🔼)</div> | 68.81%   | <div title="Base coverage is: 93.33%">94.44% (+1.11% 🔼)</div> | <div title="Base coverage is: 76.6%">77.99% (+1.39% 🔼)</div> |

</details>

## Test suite run success
653 tests passing in 94 suites.

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

tatomyr: The link checker started failing for some strange reason (Dockerhub responds with 429 status despite being available from the browser), even on the `main` branch. I'm not sure what caused that, but it's not related to the changes. 
So I'm going ahead and merging this.

Patch:
diff --git a/.changeset/eight-fans-clean.md b/.changeset/eight-fans-clean.md
new file mode 100644
index 00000000..a88574c6
--- /dev/null
+++ b/.changeset/eight-fans-clean.md
@@ -0,0 +1,6 @@
+---
+"@redocly/openapi-core": minor
+"@redocly/cli": minor
+---
+
+Added the ability to use `$ref` in the Redocly config file. This ability allows users to split up big config files and maintain their constituent parts independently.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 19daf744..b9703934 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -24,7 +24,7 @@ Before submitting a pull request, please make sure the following is done:
 1. Fork the repository and create your branch from `main`.
 1. Run `npm install` in the repository root.
 1. If you’ve fixed a bug or added code that should be tested, don't forget to add tests!
-1. Ensure the test suite passes (`npm test`). Tip: `npm test -- --watch TestName` is helpful in development.
+1. Ensure the test suite passes (`npm run test` and `npm run e2e`). Tip: to run a specific test, use this command: `npm run unit -- -t 'Test name'`. To update snapshots, run `npm run unit -- -u` for unit tests or `npm run e2e -- -u` for e2e tests.
 1. Format your code with prettier (`npm run prettier`).
 1. Each feat/fix PR should also contain a changeset (to create one, run `npx changeset`; if your changes are scoped to `packages/core` but also affect Redocly CLI behavior, please include the `@redocly/cli` package as well). Please describe what you've done in this PR using sentence case (you can refer to our [changelog](https://redocly.com/docs/cli/changelog/)). This produces a file in `.changeset` folder. Please commit this file along with your changes.
 
diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts
index 0761bc27..ee7313bb 100644
--- a/packages/cli/src/commands/lint.ts
+++ b/packages/cli/src/commands/lint.ts
@@ -1,13 +1,10 @@
 import {
   Config,
-  findConfig,
   formatProblems,
   getMergedConfig,
   getTotals,
   lint,
   lintConfig,
-  makeDocumentFromString,
-  stringifyYaml,
 } from '@redocly/openapi-core';
 import { ConfigValidationError } from '@redocly/openapi-core/lib/config';
 import {
@@ -21,11 +18,13 @@ import {
   printLintTotals,
   printUnusedWarnings,
 } from '../utils';
-import type { OutputFormat, ProblemSeverity, RawConfig, RuleSeverity } from '@redocly/openapi-core';
-import type { CommandOptions, Skips, Totals } from '../types';
 import { blue, gray } from 'colorette';
 import { performance } from 'perf_hooks';
 
+import type { OutputFormat, ProblemSeverity, Document, RuleSeverity } from '@redocly/openapi-core';
+import type { ResolvedRefMap } from '@redocly/openapi-core/lib/resolve';
+import type { CommandOptions, Skips, Totals } from '../types';
+
 export type LintOptions = {
   apis?: string[];
   'max-problems': number;
@@ -129,12 +128,10 @@ export function lintConfigCallback(
     return;
   }
 
-  return async (config: RawConfig) => {
-    const configPath = findConfig(argv.config) || '';
-    const stringYaml = stringifyYaml(config);
-    const configContent = makeDocumentFromString(stringYaml, configPath);
+  return async (document: Document, resolvedRefMap: ResolvedRefMap) => {
     const problems = await lintConfig({
-      document: configContent,
+      document,
+      resolvedRefMap,
       severity: (argv['lint-config'] || 'warn') as ProblemSeverity,
     });
 
diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts
index 7ee0fff2..73be04de 100644
--- a/packages/cli/src/utils.ts
+++ b/packages/cli/src/utils.ts
@@ -17,14 +17,12 @@ import {
   stringifyYaml,
   isAbsoluteUrl,
   loadConfig,
-  RawConfig,
   Region,
   Config,
   Oas3Definition,
   Oas2Definition,
   RedoclyClient,
 } from '@redocly/openapi-core';
-import { ConfigValidationError } from '@redocly/openapi-core/lib/config';
 import {
   Totals,
   outputExtensions,
@@ -37,6 +35,9 @@ import { isEmptyObject } from '@redocly/openapi-core/lib/utils';
 import { Arguments } from 'yargs';
 import { version } from './update-version-notifier';
 import { DESTINATION_REGEX } from './commands/push';
+import { ConfigValidationError } from '@redocly/openapi-core/lib/config';
+
+import type { RawConfigProcessor } from '@redocly/openapi-core/lib/config';
 
 export async function getFallbackApisOrExit(
   argsApis: string[] | undefined,
@@ -425,7 +426,7 @@ export async function loadConfigAndHandleErrors(
   options: {
     configPath?: string;
     customExtends?: string[];
-    processRawConfig?: (rawConfig: RawConfig) => void | Promise<void>;
+    processRawConfig?: RawConfigProcessor;
     files?: string[];
     region?: Region;
   } = {}
diff --git a/packages/core/src/bundle.ts b/packages/core/src/bundle.ts
index b6d02cb5..0c80f6e7 100755
--- a/packages/core/src/bundle.ts
+++ b/packages/core/src/bundle.ts
@@ -1,26 +1,30 @@
 import isEqual = require('lodash.isequal');
+import { BaseResolver, resolveDocument, makeRefId, makeDocumentFromString } from './resolve';
+import { normalizeVisitors } from './visitors';
+import { normalizeTypes } from './types';
+import { walkDocument } from './walk';
 import {
-  BaseResolver,
-  resolveDocument,
-  Document,
-  ResolvedRefMap,
-  makeRefId,
-  makeDocumentFromString,
-} from './resolve';
-import { Oas3Rule, normalizeVisitors, Oas3Visitor, Oas2Visitor } from './visitors';
-import { NormalizedNodeType, normalizeTypes, NodeType } from './types';
-import { WalkContext, walkDocument, UserContext, ResolveResult, NormalizedProblem } from './walk';
-import { detectSpec, getTypes, getMajorSpecVersion, SpecMajorVersion } from './oas-types';
+  detectSpec,
+  getTypes,
+  getMajorSpecVersion,
+  SpecMajorVersion,
+  SpecVersion,
+} from './oas-types';
 import { isAbsoluteUrl, isRef, Location, refBaseName } from './ref-utils';
 import { initRules } from './config/rules';
 import { reportUnresolvedRef } from './rules/no-unresolved-refs';
 import { isPlainObject, isTruthy } from './utils';
-import { OasRef } from './typings/openapi';
 import { isRedoclyRegistryURL } from './redocly';
 import { RemoveUnusedComponents as RemoveUnusedComponentsOas2 } from './decorators/oas2/remove-unused-components';
 import { RemoveUnusedComponents as RemoveUnusedComponentsOas3 } from './decorators/oas3/remove-unused-components';
+import { ConfigTypes } from './types/redocly-yaml';
 
+import type { Oas3Rule, Oas3Visitor, Oas2Visitor } from './visitors';
+import type { NormalizedNodeType, NodeType } from './types';
+import type { WalkContext, UserContext, ResolveResult, NormalizedProblem } from './walk';
 import type { Config, StyleguideConfig } from './config';
+import type { OasRef } from './typings/openapi';
+import type { Document, ResolvedRefMap } from './resolve';
 
 export type Oas3RuleSet = Record<string, Oas3Rule>;
 
@@ -33,12 +37,50 @@ export type BundleOptions = {
   externalRefResolver?: BaseResolver;
   config: Config;
   dereference?: boolean;
-  base?: string;
+  base?: string | null;
   skipRedoclyRegistryRefs?: boolean;
   removeUnusedComponents?: boolean;
   keepUrlRefs?: boolean;
 };
 
+export async function bundleConfig(document: Document, resolvedRefMap: ResolvedRefMap) {
+  const types = normalizeTypes(ConfigTypes);
+
+  const ctx: BundleContext = {
+    problems: [],
+    oasVersion: SpecVersion.OAS3_0,
+    refTypes: new Map<string, NormalizedNodeType>(),
+    visitorsData: {},
+  };
+
+  const bundleVisitor = normalizeVisitors(
+    [
+      {
+        severity: 'error',
+        ruleId: 'configBundler',
+        visitor: {
+          ref: {
+            leave(node: OasRef, ctx: UserContext, resolved: ResolveResult<any>) {
+              replaceRef(node, resolved, ctx);
+            },
+          },
+        },
+      },
+    ],
+    types
+  );
+
+  walkDocument({
+    document,
+    rootType: types.ConfigRoot,
+    normalizedVisitors: bundleVisitor,
+    resolvedRefMap,
+    ctx,
+  });
+
+  return document.parsed ?? {};
+}
+
 export async function bundle(
   opts: {
     ref?: string;
@@ -254,6 +296,18 @@ export function mapTypeToComponent(typeName: string, version: SpecMajorVersion)
   }
 }
 
+function replaceRef(ref: OasRef, resolved: ResolveResult<any>, ctx: UserContext) {
+  if (!isPlainObject(resolved.node)) {
+    ctx.parent[ctx.key] = resolved.node;
+  } else {
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-ignore
+    delete ref.$ref;
+    const obj = Object.assign({}, resolved.node, ref);
+    Object.assign(ref, obj); // assign ref itself again so ref fields take precedence
+  }
+}
+
 // function oas3Move
 
 function makeBundleVisitor(
@@ -351,19 +405,6 @@ function makeBundleVisitor(
     });
   }
 
-  function replaceRef(ref: OasRef, resolved: ResolveResult<any>, ctx: UserContext) {
-    if (!isPlainObject(resolved.node)) {
-      ctx.parent[ctx.key] = resolved.node;
-    } else {
-      // TODO: why $ref isn't optional in OasRef?
-      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-      // @ts-ignore
-      delete ref.$ref;
-      const obj = Object.assign({}, resolved.node, ref);
-      Object.assign(ref, obj); // assign ref itself again so ref fields take precedence
-    }
-  }
-
   function saveComponent(
     componentType: string,
     target: { node: any; location: Location },
diff --git a/packages/core/src/config/config-resolvers.ts b/packages/core/src/config/config-resolvers.ts
index afa0fac0..9a512d81 100644
--- a/packages/core/src/config/config-resolvers.ts
+++ b/packages/core/src/config/config-resolvers.ts
@@ -1,7 +1,7 @@
 import * as path from 'path';
 import { isAbsoluteUrl } from '../ref-utils';
 import { pickDefined } from '../utils';
-import { BaseResolver } from '../resolve';
+import { resolveDocument, BaseResolver } from '../resolve';
 import { defaultPlugin } from './builtIn';
 import {
   getResolveConfig,
@@ -11,6 +11,14 @@ import {
   prefixRules,
   transformConfig,
 } from './utils';
+import { isBrowser } from '../env';
+import { isNotString, isString, isDefined, parseYaml, keysOf } from '../utils';
+import { Config } from './config';
+import { colorize, logger } from '../logger';
+import { asserts, buildAssertCustomFunction } from '../rules/common/assertions/asserts';
+import { normalizeTypes } from '../types';
+import { ConfigTypes } from '../types/redocly-yaml';
+
 import type {
   StyleguideRawConfig,
   ApiStyleguideRawConfig,
@@ -21,17 +29,39 @@ import type {
   RuleConfig,
   DeprecatedInRawConfig,
 } from './types';
-import { isBrowser } from '../env';
-import { isNotString, isString, isDefined, parseYaml, keysOf } from '../utils';
-import { Config } from './config';
-import { colorize, logger } from '../logger';
-import {
-  Asserts,
-  AssertionFn,
-  asserts,
-  buildAssertCustomFunction,
-} from '../rules/common/assertions/asserts';
 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';
+
+export async function resolveConfigFileAndRefs({
+  configPath,
+  externalRefResolver = new BaseResolver(),
+  base = null,
+}: Omit<BundleOptions, 'config'> & { configPath?: string }): Promise<{
+  document: Document;
+  resolvedRefMap: ResolvedRefMap;
+}> {
+  if (!configPath) {
+    throw new Error('Reference to a config is required.\n');
+  }
+
+  const document = await externalRefResolver.resolveDocument(base, configPath, true);
+
+  if (document instanceof Error) {
+    throw document;
+  }
+
+  const types = normalizeTypes(ConfigTypes);
+
+  const resolvedRefMap = await resolveDocument({
+    rootDocument: document,
+    rootType: types.ConfigRoot,
+    externalRefResolver,
+  });
+
+  return { document, resolvedRefMap };
+}
 
 export async function resolveConfig(rawConfig: RawConfig, configPath?: string): Promise<Config> {
   if (rawConfig.styleguide?.extends?.some(isNotString)) {
diff --git a/packages/core/src/config/load.ts b/packages/core/src/config/load.ts
index 9c0b9ba9..c7ba384f 100644
--- a/packages/core/src/config/load.ts
+++ b/packages/core/src/config/load.ts
@@ -1,20 +1,17 @@
 import * as fs from 'fs';
 import * as path from 'path';
 import { RedoclyClient } from '../redocly';
-import { isEmptyObject, loadYaml, doesYamlFileExist } from '../utils';
+import { isEmptyObject, doesYamlFileExist } from '../utils';
 import { parseYaml } from '../js-yaml';
 import { Config, DOMAINS } from './config';
 import { ConfigValidationError, transformConfig } from './utils';
-import { resolveConfig } from './config-resolvers';
+import { resolveConfig, resolveConfigFileAndRefs } from './config-resolvers';
+import { bundleConfig } from '../bundle';
 
-import type {
-  DeprecatedInRawConfig,
-  FlatRawConfig,
-  RawConfig,
-  RawUniversalConfig,
-  Region,
-} from './types';
-import { RegionalTokenWithValidity } from '../redocly/redocly-client-types';
+import type { Document } from '../resolve';
+import type { RegionalTokenWithValidity } from '../redocly/redocly-client-types';
+import type { RawConfig, RawUniversalConfig, Region } from './types';
+import type { BaseResolver, ResolvedRefMap } from '../resolve';
 
 async function addConfigMetadata({
   rawConfig,
@@ -73,17 +70,30 @@ async function addConfigMetadata({
   );
 }
 
+export type RawConfigProcessor = (
+  rawConfig: Document,
+  resolvedRefMap: ResolvedRefMap
+) => void | Promise<void>;
+
 export async function loadConfig(
   options: {
     configPath?: string;
     customExtends?: string[];
-    processRawConfig?: (rawConfig: RawConfig) => void | Promise<void>;
+    processRawConfig?: RawConfigProcessor;
+    externalRefResolver?: BaseResolver;
     files?: string[];
     region?: Region;
   } = {}
 ): Promise<Config> {
-  const { configPath = findConfig(), customExtends, processRawConfig, files, region } = options;
-  const rawConfig = await getConfig(configPath, processRawConfig);
+  const {
+    configPath = findConfig(),
+    customExtends,
+    processRawConfig,
+    files,
+    region,
+    externalRefResolver,
+  } = options;
+  const rawConfig = await getConfig({ configPath, processRawConfig, externalRefResolver });
 
   const redoclyClient = new RedoclyClient();
   const tokens = await redoclyClient.getTokens();
@@ -116,17 +126,24 @@ export function findConfig(dir?: string): string | undefined {
 }
 
 export async function getConfig(
-  configPath: string | undefined = findConfig(),
-  processRawConfig?: (rawConfig: RawConfig) => void | Promise<void>
+  options: {
+    configPath?: string;
+    processRawConfig?: RawConfigProcessor;
+    externalRefResolver?: BaseResolver;
+  } = {}
 ): Promise<RawConfig> {
+  const { configPath = findConfig(), processRawConfig, externalRefResolver } = options;
   if (!configPath || !doesYamlFileExist(configPath)) return {};
   try {
-    const rawConfig =
-      (await loadYaml<RawConfig & DeprecatedInRawConfig & FlatRawConfig>(configPath)) || {};
+    const { document, resolvedRefMap } = await resolveConfigFileAndRefs({
+      configPath,
+      externalRefResolver,
+    });
     if (typeof processRawConfig === 'function') {
-      await processRawConfig(rawConfig);
+      await processRawConfig(document, resolvedRefMap);
     }
-    return transformConfig(rawConfig);
+    const bundledConfig = await bundleConfig(document, resolvedRefMap);
+    return transformConfig(bundledConfig);
   } catch (e) {
     if (e instanceof ConfigValidationError) {
       throw e;
diff --git a/packages/core/src/lint.ts b/packages/core/src/lint.ts
index 6eb2c528..f6a93b63 100755
--- a/packages/core/src/lint.ts
+++ b/packages/core/src/lint.ts
@@ -1,13 +1,18 @@
-import { BaseResolver, resolveDocument, Document, makeDocumentFromString } from './resolve';
+import { BaseResolver, resolveDocument, makeDocumentFromString } from './resolve';
 import { normalizeVisitors } from './visitors';
-import { NodeType } from './types';
-import { ProblemSeverity, WalkContext, walkDocument } from './walk';
+import { walkDocument } from './walk';
 import { StyleguideConfig, Config, initRules, defaultPlugin, resolvePlugins } from './config';
 import { normalizeTypes } from './types';
 import { releaseAjvInstance } from './rules/ajv';
 import { SpecVersion, getMajorSpecVersion, detectSpec, getTypes } from './oas-types';
 import { ConfigTypes } from './types/redocly-yaml';
 import { Spec } from './rules/common/spec';
+import { NoUnresolvedRefs } from './rules/no-unresolved-refs';
+
+import type { Document, ResolvedRefMap } from './resolve';
+import type { ProblemSeverity, WalkContext } from './walk';
+import type { NodeType } from './types';
+import type { NestedVisitObject, Oas3Visitor, RuleInstanceConfig } from './visitors';
 
 export async function lint(opts: {
   ref: string;
@@ -102,8 +107,13 @@ export async function lintDocument(opts: {
   return ctx.problems.map((problem) => config.addProblemToIgnore(problem));
 }
 
-export async function lintConfig(opts: { document: Document; severity?: ProblemSeverity }) {
-  const { document, severity } = opts;
+export async function lintConfig(opts: {
+  document: Document;
+  resolvedRefMap?: ResolvedRefMap;
+  severity?: ProblemSeverity;
+  externalRefResolver?: BaseResolver;
+}) {
+  const { document, severity, externalRefResolver = new BaseResolver() } = opts;
 
   const ctx: WalkContext = {
     problems: [],
@@ -117,21 +127,33 @@ export async function lintConfig(opts: { document: Document; severity?: ProblemS
   });
 
   const types = normalizeTypes(ConfigTypes, config);
-  const rules = [
+  const rules: (RuleInstanceConfig & {
+    visitor: NestedVisitObject<unknown, Oas3Visitor | Oas3Visitor[]>;
+  })[] = [
     {
       severity: severity || 'error',
       ruleId: 'configuration spec',
       visitor: Spec({ severity: 'error' }),
     },
+    {
+      severity: severity || 'error',
+      ruleId: 'configuration no-unresolved-refs',
+      visitor: NoUnresolvedRefs({ severity: 'error' }),
+    },
   ];
-  // TODO: check why any is needed
-  const normalizedVisitors = normalizeVisitors(rules as any, types);
-
+  const normalizedVisitors = normalizeVisitors(rules, types);
+  const resolvedRefMap =
+    opts.resolvedRefMap ||
+    (await resolveDocument({
+      rootDocument: document,
+      rootType: types.ConfigRoot,
+      externalRefResolver,
+    }));
   walkDocument({
     document,
     rootType: types.ConfigRoot,
     normalizedVisitors,
-    resolvedRefMap: new Map(),
+    resolvedRefMap,
     ctx,
   });
 
diff --git a/packages/core/src/types/portal-config-schema.ts b/packages/core/src/types/portal-config-schema.ts
index 182d5108..e48e6719 100644
--- a/packages/core/src/types/portal-config-schema.ts
+++ b/packages/core/src/types/portal-config-schema.ts
@@ -4,6 +4,8 @@ import {
   DEFAULT_TEAM_CLAIM_NAME,
 } from '../config';
 
+import type { NodeType } from '.';
+
 const oidcIssuerMetadataSchema = {
   type: 'object',
   properties: {
@@ -87,8 +89,9 @@ const authProviderConfigSchema = {
 
 export const ssoConfigSchema = {
   type: 'object',
+  properties: {},
   additionalProperties: authProviderConfigSchema,
-} as const;
+} as NodeType;
 
 const redirectConfigSchema = {
   type: 'object',
@@ -97,8 +100,14 @@ const redirectConfigSchema = {
     type: { type: 'number', default: 301 },
   },
   required: ['to'],
-  additionalProperties: false,
-} as const;
+} as NodeType;
+
+const redirectsConfigSchema = {
+  type: 'object',
+  properties: {},
+  additionalProperties: 'redirectConfigSchema',
+  default: {},
+} as NodeType;
 
 export const apiConfigSchema = {
   type: 'object',
@@ -147,18 +156,21 @@ const seoConfigSchema = {
       },
     },
   },
-  additionalProperties: false,
 } as const;
 
-const rbacScopeItemsSchema = { type: 'object', additionalProperties: { type: 'string' } } as const;
+const rbacScopeItemsSchema = {
+  type: 'object',
+  properties: {},
+  additionalProperties: { type: 'string' },
+} as NodeType;
 
 const rbacConfigSchema = {
   type: 'object',
   properties: {
-    defaults: rbacScopeItemsSchema,
+    defaults: 'rbacScopeItemsSchema',
   },
   additionalProperties: rbacScopeItemsSchema,
-} as const;
+} as NodeType;
 
 const graviteeAdapterConfigSchema = {
   type: 'object',
@@ -234,14 +246,38 @@ const devOnboardingAdapterConfigSchema = {
 const devOnboardingConfigSchema = {
   type: 'object',
   required: ['adapters'],
-  additionalProperties: false,
   properties: {
     adapters: {
       type: 'array',
       items: devOnboardingAdapterConfigSchema,
     },
   },
-} as const;
+} as NodeType;
+
+const i18ConfigSchema = {
+  type: 'object',
+  properties: {
+    defaultLocale: {
+      type: 'string',
+    },
+    locales: {
+      type: 'array',
+      items: {
+        type: 'object',
+        properties: {
+          code: {
+            type: 'string',
+          },
+          name: {
+            type: 'string',
+          },
+        },
+        required: ['code'],
+      },
+    },
+  },
+  required: ['defaultLocale', 'locales'],
+} as NodeType;
 
 const responseHeaderSchema = {
   type: 'object',
@@ -253,14 +289,25 @@ const responseHeaderSchema = {
   required: ['name', 'value'],
 } as const;
 
+export const PortalConfigNodeTypes: Record<string, NodeType> = {
+  seoConfigSchema,
+  rbacConfigSchema,
+  rbacScopeItemsSchema,
+  ssoConfigSchema,
+  devOnboardingConfigSchema,
+  i18ConfigSchema,
+  redirectsConfigSchema,
+  redirectConfigSchema,
+  // TODO: Extract other types that need to be linted in the config
+};
+
 export const redoclyConfigSchema = {
   type: 'object',
   properties: {
     licenseKey: { type: 'string' },
-    theme: { type: 'object', default: {} }, // ThemeConfig
-    redirects: { type: 'object', additionalProperties: redirectConfigSchema, default: {} },
-    seo: seoConfigSchema,
-    rbac: rbacConfigSchema,
+    redirects: 'redirectsConfigSchema',
+    seo: 'seoConfigSchema',
+    rbac: 'rbacConfigSchema',
     responseHeaders: {
       type: 'object',
       additionalProperties: {
@@ -282,38 +329,13 @@ export const redoclyConfigSchema = {
       type: 'object',
       additionalProperties: apiConfigSchema,
     },
-    sso: ssoConfigSchema,
-    developerOnboarding: devOnboardingConfigSchema,
-    i18n: {
-      type: 'object',
-      properties: {
-        defaultLocale: {
-          type: 'string',
-        },
-        locales: {
-          type: 'array',
-          items: {
-            type: 'object',
-            properties: {
-              code: {
-                type: 'string',
-              },
-              name: {
-                type: 'string',
-              },
-            },
-            required: ['code'],
-          },
-        },
-      },
-      additionalProperties: false,
-      required: ['defaultLocale', 'locales'],
-    },
+    sso: 'ssoConfigSchema',
+    developerOnboarding: 'devOnboardingConfigSchema',
+    i18n: 'i18ConfigSchema',
     metadata: metadataConfigSchema,
   },
   default: {},
-  additionalProperties: true,
-} as const;
+} as NodeType;
 
 export const environmentSchema = {
   oneOf: [
@@ -335,6 +357,7 @@ export const rootRedoclyConfigSchema = {
     ...redoclyConfigSchema.properties,
     env: {
       type: 'object',
+      properties: {},
       additionalProperties: environmentSchema,
     },
   },
diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts
index 9e4e5678..0fcc9ff4 100644
--- a/packages/core/src/types/redocly-yaml.ts
+++ b/packages/core/src/types/redocly-yaml.ts
@@ -1,8 +1,8 @@
 import { rootRedoclyConfigSchema, apiConfigSchema } from './portal-config-schema';
 import { themeConfigSchema } from './theme-config';
 import { NodeType, listOf } from '.';
-import { Oas3_1Types } from './oas3_1';
 import { omitObjectProps, pickObjectProps, isCustomRuleId } from '../utils';
+import { PortalConfigNodeTypes } from './portal-config-schema';
 
 const builtInCommonRules = [
   'spec',
@@ -236,8 +236,6 @@ const ConfigApisProperties: NodeType = {
         type: 'string',
       },
     },
-    lint: 'ConfigStyleguide', // deprecated
-    styleguide: 'ConfigStyleguide', // deprecated
     ...ConfigStyleguide.properties,
     'features.openapi': 'ConfigReferenceDocs', // deprecated
     'features.mockServer': 'ConfigMockServer', // deprecated
@@ -997,7 +995,6 @@ const ConfigMockServer: NodeType = {
 };
 
 export const ConfigTypes: Record<string, NodeType> = {
-  ...Oas3_1Types,
   Assert,
   ConfigRoot,
   ConfigApis,
@@ -1062,4 +1059,5 @@ export const ConfigTypes: Record<string, NodeType> = {
   Typography,
   AssertionDefinitionAssertions,
   AssertionDefinitionSubject,
+  ...PortalConfigNodeTypes,
 };
diff --git a/packages/core/src/visitors.ts b/packages/core/src/visitors.ts
index 3cd7a54a..18c5b477 100644
--- a/packages/core/src/visitors.ts
+++ b/packages/core/src/visitors.ts
@@ -1,3 +1,9 @@
+import { SpecExtension } from './types';
+
+import type { NormalizedNodeType } from './types';
+import type { Stack } from './utils';
+import type { UserContext, ResolveResult, ProblemSeverity } from './walk';
+import type { Location } from './ref-utils';
 import type {
   Oas3Definition,
   Oas3ExternalDocs,
@@ -26,7 +32,6 @@ import type {
   Oas3Discriminator,
   Oas3Callback,
 } from './typings/openapi';
-
 import type {
   Oas2Definition,
   Oas2Tag,
@@ -44,12 +49,7 @@ import type {
   Oas2Parameter,
   Oas2SecurityScheme,
 } from './typings/swagger';
-
-import { NormalizedNodeType, SpecExtension } from './types';
-import type { Stack } from './utils';
-import type { UserContext, ResolveResult, ProblemSeverity } from './walk';
-import type { Location } from './ref-utils';
-import { Async2Definition } from './typings/asyncapi';
+import type { Async2Definition } from './typings/asyncapi';
 
 export type SkipFunctionContext = Pick<
   UserContext,
@@ -297,7 +297,7 @@ export type RuleInstanceConfig = {
 };
 
 export function normalizeVisitors<T extends BaseVisitor>(
-  visitorsConfig: (RuleInstanceConfig & { visitor: NestedVisitObject<any, T> })[],
+  visitorsConfig: (RuleInstanceConfig & { visitor: NestedVisitObject<unknown, T> })[],
   types: Record<keyof T, NormalizedNodeType>
 ): NormalizedOasVisitors<T> {
   const normalizedVisitors: NormalizedOasVisitors<T> = {} as any;
@@ -377,12 +377,10 @@ export function normalizeVisitors<T extends BaseVisitor>(
 
     function addWeakFromStack(ruleConf: RuleInstanceConfig, stack: NormalizedNodeType[]) {
       for (const interType of stack.slice(1)) {
-        (normalizedVisitors as any)[interType.name] =
-          normalizedVisitors[interType.name] ||
-          ({
-            enter: [],
-            leave: [],
-          } as any);
+        (normalizedVisitors as any)[interType.name] = normalizedVisitors[interType.name] || {
+          enter: [],
+          leave: [],
+        };
         normalizedVisitors[interType.name].enter.push({
           ...ruleConf,
           visit: () => undefined,
@@ -398,7 +396,7 @@ export function normalizeVisitors<T extends BaseVisitor>(
   }
 
   function findLegacyVisitorNode<T>(
-    visitor: NestedVisitObject<any, T>,
+    visitor: NestedVisitObject<unknown, T>,
     typeName: keyof T | Array<keyof T>
   ) {
     if (Array.isArray(typeName)) {
@@ -411,7 +409,7 @@ export function normalizeVisitors<T extends BaseVisitor>(
 
   function normalizeVisitorLevel(
     ruleConf: RuleInstanceConfig,
-    visitor: NestedVisitObject<any, T>,
+    visitor: NestedVisitObject<unknown, T>,
     parentContext: VisitorLevelContext | null,
     depth = 0
   ) {
@@ -434,14 +432,14 @@ export function normalizeVisitors<T extends BaseVisitor>(
         findLegacyVisitorNode(
           visitor,
           legacyTypesMap[typeName as keyof typeof legacyTypesMap] as keyof T
-        )) as any as NestedVisitObject<any, T>;
+        )) as NestedVisitObject<unknown, T>;
       const normalizedTypeVisitor = normalizedVisitors[typeName];
 
       if (!typeVisitor) continue;
 
-      let visitorEnter: VisitFunction<any> | undefined;
-      let visitorLeave: VisitFunction<any> | undefined;
-      let visitorSkip: SkipFunction<any> | undefined;
+      let visitorEnter: VisitFunction<unknown> | undefined;
+      let visitorLeave: VisitFunction<unknown> | undefined;
+      let visitorSkip: SkipFunction<unknown> | undefined;
 
       const isObjectVisitor = typeof typeVisitor === 'object';
 
@@ -450,7 +448,7 @@ export function normalizeVisitors<T extends BaseVisitor>(
       }
 
       if (typeof typeVisitor === 'function') {
-        visitorEnter = typeVisitor as any;
+        visitorEnter = typeVisitor;
       } else if (isObjectVisitor) {
         visitorEnter = typeVisitor.enter;
         visitorLeave = typeVisitor.leave;
@@ -465,7 +463,7 @@ export function normalizeVisitors<T extends BaseVisitor>(
       };
 
       if (typeof typeVisitor === 'object') {
-        normalizeVisitorLevel(ruleConf, typeVisitor as any, context, depth + 1);
+        normalizeVisitorLevel(ruleConf, typeVisitor, context, depth + 1);
       }
 
       if (parentContext) {
diff --git a/packages/core/src/walk.ts b/packages/core/src/walk.ts
index 1e18a81f..e0e4494c 100644
--- a/packages/core/src/walk.ts
+++ b/packages/core/src/walk.ts
@@ -1,3 +1,9 @@
+import { Location, isRef } from './ref-utils';
+import { pushStack, popStack } from './utils';
+import { ResolveError, YamlParseError, Source, makeRefId } from './resolve';
+import { SpecVersion } from './oas-types';
+import { isNamedType, SpecExtension } from './types';
+
 import type { Referenced } from './typings/openapi';
 import type {
   VisitorLevelContext,
@@ -12,12 +18,6 @@ import type { ResolvedRefMap, Document } from './resolve';
 import type { NormalizedNodeType } from './types';
 import type { RuleSeverity } from './config';
 
-import { Location, isRef } from './ref-utils';
-import { pushStack, popStack } from './utils';
-import { ResolveError, YamlParseError, Source, makeRefId } from './resolve';
-import { SpecVersion } from './oas-types';
-import { isNamedType, SpecExtension } from './types';
-
 export type NonUndefined =
   | string
   | number
@@ -204,7 +204,7 @@ export function walkDocument<T extends BaseVisitor>(opts: {
       const activatedContexts: Array<VisitorSkippedLevelContext | VisitorLevelContext> = [];
 
       for (const { context, visit, skip, ruleId, severity } of currentEnterVisitors) {
-        if (ignoredNodes.has(currentLocation.pointer)) break;
+        if (ignoredNodes.has(`${currentLocation.absolutePointer}${currentLocation.pointer}`)) break;
 
         if (context.isSkippedLevel) {
           if (
@@ -413,7 +413,7 @@ export function walkDocument<T extends BaseVisitor>(opts: {
           parentLocations: collectParentsLocations(context),
           oasVersion: ctx.oasVersion,
           ignoreNextVisitorsOnNode: () => {
-            ignoredNodes.add(currentLocation.pointer);
+            ignoredNodes.add(`${currentLocation.absolutePointer}${currentLocation.pointer}`);
           },
           getVisitorData: getVisitorDataFn.bind(undefined, ruleId),
         },


Test patch:
diff --git a/__tests__/commands.test.ts b/__tests__/commands.test.ts
index 61e96d0b..e681ffa2 100644
--- a/__tests__/commands.test.ts
+++ b/__tests__/commands.test.ts
@@ -63,6 +63,8 @@ describe('E2E', () => {
       { dirName: 'invalid-config-assertation-name', option: 'warn' },
       { dirName: 'invalid-config-assertation-config-type', option: 'warn' },
       { dirName: 'invalid-config-format-json', option: 'warn', format: 'json' },
+      { dirName: 'config-with-refs', option: 'warn' },
+      { dirName: 'config-with-refs-extended', option: 'error' },
     ];
 
     const validOpenapiFile = join(__dirname, 'lint-config/__fixtures__/valid-openapi.yaml');
diff --git a/__tests__/lint-config/config-with-refs-extended/.redocly.yaml b/__tests__/lint-config/config-with-refs-extended/.redocly.yaml
new file mode 100644
index 00000000..f455f7d1
--- /dev/null
+++ b/__tests__/lint-config/config-with-refs-extended/.redocly.yaml
@@ -0,0 +1,16 @@
+unexpected-property: Should fail
+
+apis:
+  $ref: apis.yaml
+
+rules:
+  $ref: ./rules.yaml
+
+theme:
+  openapi:
+    $ref: theme-openapi.yaml
+
+organization: false # wrong, expected string
+
+seo:
+  $ref: seo.yaml
diff --git a/__tests__/lint-config/config-with-refs-extended/apis.yaml b/__tests__/lint-config/config-with-refs-extended/apis.yaml
new file mode 100644
index 00000000..beda86fe
--- /dev/null
+++ b/__tests__/lint-config/config-with-refs-extended/apis.yaml
@@ -0,0 +1,2 @@
+test@v1:
+  $ref: v1.yaml
diff --git a/__tests__/lint-config/config-with-refs-extended/rules.yaml b/__tests__/lint-config/config-with-refs-extended/rules.yaml
new file mode 100644
index 00000000..fbe865e8
--- /dev/null
+++ b/__tests__/lint-config/config-with-refs-extended/rules.yaml
@@ -0,0 +1,2 @@
+info-contact: error
+wrong-rule: warn
diff --git a/__tests__/lint-config/config-with-refs-extended/seo.yaml b/__tests__/lint-config/config-with-refs-extended/seo.yaml
new file mode 100644
index 00000000..282b43e6
--- /dev/null
+++ b/__tests__/lint-config/config-with-refs-extended/seo.yaml
@@ -0,0 +1,1 @@
+title: 222 # wrong, expected string
diff --git a/__tests__/lint-config/config-with-refs-extended/snapshot.js b/__tests__/lint-config/config-with-refs-extended/snapshot.js
new file mode 100644
index 00000000..17bf398c
--- /dev/null
+++ b/__tests__/lint-config/config-with-refs-extended/snapshot.js
@@ -0,0 +1,87 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`E2E lint-config test with option: { dirName: 'config-with-refs-extended', option: 'error' } 1`] = `
+
+[1] .redocly.yaml:1:1 at #/unexpected-property
+
+Property \`unexpected-property\` is not expected here.
+
+1 | unexpected-property: Should fail
+  | ^^^^^^^^^^^^^^^^^^^
+2 |
+3 | apis:
+
+Error was generated by the configuration spec rule.
+
+
+[2] .redocly.yaml:13:15 at #/organization
+
+Expected type \`string\` but got \`boolean\`.
+
+11 |     $ref: theme-openapi.yaml
+12 |
+13 | organization: false # wrong, expected string
+   |               ^^^^^
+14 |
+15 | seo:
+
+Error was generated by the configuration spec rule.
+
+
+[3] seo.yaml:1:8 at #/title
+
+Expected type \`string\` but got \`integer\`.
+
+1 | title: 222 # wrong, expected string
+  |        ^^^
+2 |
+
+referenced from .redocly.yaml:16:3 at #/seo 
+
+Error was generated by the configuration spec rule.
+
+
+[4] v1.yaml:1:7 at #/root
+
+Expected type \`string\` but got \`boolean\`.
+
+1 | root: true # wrong, expected string
+  |       ^^^^
+2 |
+
+referenced from apis.yaml:2:3 at #/test@v1 
+
+Error was generated by the configuration spec rule.
+
+
+[5] rules.yaml:2:1 at #/wrong-rule
+
+Property \`wrong-rule\` is not expected here.
+
+1 | info-contact: error
+2 | wrong-rule: warn
+  | ^^^^^^^^^^
+3 |
+
+referenced from .redocly.yaml:7:3 at #/rules 
+
+Error was generated by the configuration spec rule.
+
+
+[6] theme-openapi.yaml:3:15 at #/theme/logo/maxWidth
+
+Expected type \`string\` but got \`integer\`.
+
+1 | theme:
+2 |   logo:
+3 |     maxWidth: 100 # Fail - should be a string
+  |               ^^^
+4 |
+
+Error was generated by the configuration spec rule.
+
+
+❌ Your config has 6 errors.
+
+
+`;
diff --git a/__tests__/lint-config/config-with-refs-extended/theme-openapi.yaml b/__tests__/lint-config/config-with-refs-extended/theme-openapi.yaml
new file mode 100644
index 00000000..257d4f66
--- /dev/null
+++ b/__tests__/lint-config/config-with-refs-extended/theme-openapi.yaml
@@ -0,0 +1,3 @@
+theme:
+  logo:
+    maxWidth: 100 # Fail - should be a string
diff --git a/__tests__/lint-config/config-with-refs-extended/v1.yaml b/__tests__/lint-config/config-with-refs-extended/v1.yaml
new file mode 100644
index 00000000..d3ce8bca
--- /dev/null
+++ b/__tests__/lint-config/config-with-refs-extended/v1.yaml
@@ -0,0 +1,1 @@
+root: true # wrong, expected string
diff --git a/__tests__/lint-config/config-with-refs/.redocly.yaml b/__tests__/lint-config/config-with-refs/.redocly.yaml
new file mode 100644
index 00000000..f28574ff
--- /dev/null
+++ b/__tests__/lint-config/config-with-refs/.redocly.yaml
@@ -0,0 +1,11 @@
+apis:
+  main:
+    root: ./openapi.yaml
+    rules:
+      $ref: rules.yaml
+
+non-existing-root-property: Fail
+
+theme:
+  openapi:
+    $ref: ./theme-openapi.yaml#/content
diff --git a/__tests__/lint-config/config-with-refs/rules.yaml b/__tests__/lint-config/config-with-refs/rules.yaml
new file mode 100644
index 00000000..fbe865e8
--- /dev/null
+++ b/__tests__/lint-config/config-with-refs/rules.yaml
@@ -0,0 +1,2 @@
+info-contact: error
+wrong-rule: warn
diff --git a/__tests__/lint-config/config-with-refs/snapshot.js b/__tests__/lint-config/config-with-refs/snapshot.js
new file mode 100644
index 00000000..394ea459
--- /dev/null
+++ b/__tests__/lint-config/config-with-refs/snapshot.js
@@ -0,0 +1,50 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`E2E lint-config test with option: { dirName: 'config-with-refs', option: 'warn' } 1`] = `
+
+[1] .redocly.yaml:7:1 at #/non-existing-root-property
+
+Property \`non-existing-root-property\` is not expected here.
+
+5 |       $ref: rules.yaml
+6 |
+7 | non-existing-root-property: Fail
+  | ^^^^^^^^^^^^^^^^^^^^^^^^^^
+8 |
+9 | theme:
+
+Warning was generated by the configuration spec rule.
+
+
+[2] rules.yaml:2:1 at #/wrong-rule
+
+Property \`wrong-rule\` is not expected here.
+
+1 | info-contact: error
+2 | wrong-rule: warn
+  | ^^^^^^^^^^
+3 |
+
+referenced from .redocly.yaml:5:7 at #/apis/main/rules 
+
+Warning was generated by the configuration spec rule.
+
+
+[3] theme-openapi.yaml:4:17 at #/content/theme/logo/maxWidth
+
+Expected type \`string\` but got \`integer\`.
+
+2 | theme:
+3 |   logo:
+4 |     maxWidth: 100 # Fail - should be a string
+  |               ^^^
+5 |
+
+Warning was generated by the configuration spec rule.
+
+
+⚠️ Your config has 3 warnings.
+⚠️ No rules were configured. Learn how to configure rules: https://redocly.com/docs/cli/rules/
+
+
+`;
diff --git a/__tests__/lint-config/config-with-refs/theme-openapi.yaml b/__tests__/lint-config/config-with-refs/theme-openapi.yaml
new file mode 100644
index 00000000..9e80af4e
--- /dev/null
+++ b/__tests__/lint-config/config-with-refs/theme-openapi.yaml
@@ -0,0 +1,4 @@
+content:
+  theme:
+    logo:
+      maxWidth: 100 # Fail - should be a string
diff --git a/__tests__/lint-config/invalid-config--lint-config-error/snapshot.js b/__tests__/lint-config/invalid-config--lint-config-error/snapshot.js
index da6fe49b..a9751250 100644
--- a/__tests__/lint-config/invalid-config--lint-config-error/snapshot.js
+++ b/__tests__/lint-config/invalid-config--lint-config-error/snapshot.js
@@ -2,15 +2,15 @@
 
 exports[`E2E lint-config test with option: { dirName: 'invalid-config--lint-config-error', option: 'error' } 1`] = `
 
-[1] .redocly.yaml:5:3 at #/rules/context
+[1] .redocly.yaml:6:3 at #/rules/context
 
 Property \`context\` is not expected here.
 
-3 |     root: ./openapi.yaml
-4 | rules:
-5 |   context: null
-  |   ^^^^^^^
-6 |
+4 |
+5 | rules:
+6 |   context:
+  |   ^^^^^^^^
+7 |
 
 Error was generated by the configuration spec rule.
 
diff --git a/__tests__/lint-config/invalid-config--lint-config-warn/snapshot.js b/__tests__/lint-config/invalid-config--lint-config-warn/snapshot.js
index 6fe44b25..4d18c6cf 100644
--- a/__tests__/lint-config/invalid-config--lint-config-warn/snapshot.js
+++ b/__tests__/lint-config/invalid-config--lint-config-warn/snapshot.js
@@ -2,15 +2,15 @@
 
 exports[`E2E lint-config test with option: { dirName: 'invalid-config--lint-config-warn', option: 'warn' } 1`] = `
 
-[1] .redocly.yaml:5:3 at #/rules/context
+[1] .redocly.yaml:6:3 at #/rules/context
 
 Property \`context\` is not expected here.
 
-3 |     root: ./openapi.yaml
-4 | rules:
-5 |   context: null
-  |   ^^^^^^^
-6 |
+4 |
+5 | rules:
+6 |   context:
+  |   ^^^^^^^^
+7 |
 
 Warning was generated by the configuration spec rule.
 
diff --git a/__tests__/lint-config/invalid-config--no-option/snapshot.js b/__tests__/lint-config/invalid-config--no-option/snapshot.js
index 25fe6ccf..74d72e74 100644
--- a/__tests__/lint-config/invalid-config--no-option/snapshot.js
+++ b/__tests__/lint-config/invalid-config--no-option/snapshot.js
@@ -2,15 +2,15 @@
 
 exports[`E2E lint-config test with option: { dirName: 'invalid-config--no-option', option: null } 1`] = `
 
-[1] .redocly.yaml:5:3 at #/rules/context
+[1] .redocly.yaml:6:3 at #/rules/context
 
 Property \`context\` is not expected here.
 
-3 |     root: ./openapi.yaml
-4 | rules:
-5 |   context: null
-  |   ^^^^^^^
-6 |
+4 |
+5 | rules:
+6 |   context:
+  |   ^^^^^^^^
+7 |
 
 Warning was generated by the configuration spec rule.
 
diff --git a/__tests__/lint-config/invalid-config-assertation-config-type/snapshot.js b/__tests__/lint-config/invalid-config-assertation-config-type/snapshot.js
index bb7df83c..0cd54919 100644
--- a/__tests__/lint-config/invalid-config-assertation-config-type/snapshot.js
+++ b/__tests__/lint-config/invalid-config-assertation-config-type/snapshot.js
@@ -2,16 +2,16 @@
 
 exports[`E2E lint-config test with option: { dirName: 'invalid-config-assertation-config-type', option: 'warn' } 1`] = `
 
-[1] .redocly.yaml:8:17 at #/rules/assert~1path-item-mutually-required/where/0/subject/type
+[1] .redocly.yaml:9:17 at #/rules/assert~1path-item-mutually-required/where/0/subject/type
 
 \`type\` can be one of the following only: "any", "Root", "Tag", "TagList", "ExternalDocs", "Server", "ServerList", "ServerVariable", "ServerVariablesMap", "SecurityRequirement", "SecurityRequirementList", "Info", "Contact", "License", "Paths", "PathItem", "Parameter", "ParameterList", "Operation", "Callback", "CallbacksMap", "RequestBody", "MediaTypesMap", "MediaType", "Example", "ExamplesMap", "Encoding", "EncodingMap", "Header", "HeadersMap", "Responses", "Response", "Link", "LinksMap", "Schema", "Xml", "SchemaProperties", "DiscriminatorMapping", "Discriminator", "Components", "NamedSchemas", "NamedResponses", "NamedParameters", "NamedExamples", "NamedRequestBodies", "NamedHeaders", "NamedSecuritySchemes", "NamedLinks", "NamedCallbacks", "ImplicitFlow", "PasswordFlow", "ClientCredentials", "AuthorizationCode", "OAuth2Flows", "SecurityScheme", "XCodeSample", "XCodeSampleList", "WebhooksMap", "SpecExtension", "Message".
 
- 6 | where:
- 7 |   - subject:
- 8 |       type: Invalid-type
+ 7 | where:
+ 8 |   - subject:
+ 9 |       type: Invalid-type
    |             ^^^^^^^^^^^^
- 9 |       property: property
-10 |     assertions:
+10 |       property: property
+11 |     assertions:
 
 Warning was generated by the configuration spec rule.
 
diff --git a/__tests__/lint-config/invalid-config-assertation-name/snapshot.js b/__tests__/lint-config/invalid-config-assertation-name/snapshot.js
index 1cdd5018..76417a94 100644
--- a/__tests__/lint-config/invalid-config-assertation-name/snapshot.js
+++ b/__tests__/lint-config/invalid-config-assertation-name/snapshot.js
@@ -2,16 +2,16 @@
 
 exports[`E2E lint-config test with option: { dirName: 'invalid-config-assertation-name', option: 'warn' } 1`] = `
 
-[1] .redocly.yaml:5:3 at #/rules/asset~1path-item-mutually-required
+[1] .redocly.yaml:6:3 at #/rules/asset~1path-item-mutually-required
 
 The field \`severity\` must be present on this level.
 
-3 |     root: ./openapi.yaml
-4 | rules:
-5 |   asset/path-item-mutually-required:
+4 |
+5 | rules:
+6 |   asset/path-item-mutually-required:
   |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-6 |     context:
-7 |       - type: PathItem
+7 |     context:
+8 |       - type: PathItem
 
 Warning was generated by the configuration spec rule.
 
diff --git a/__tests__/lint-config/invalid-definition-and-config/config-with-error.snapshot.js b/__tests__/lint-config/invalid-definition-and-config/config-with-error.snapshot.js
index c2a89496..4e0e221b 100644
--- a/__tests__/lint-config/invalid-definition-and-config/config-with-error.snapshot.js
+++ b/__tests__/lint-config/invalid-definition-and-config/config-with-error.snapshot.js
@@ -6,16 +6,16 @@ exports[`E2E lint-config invalid-definition-and-config: {
   snapshot: 'config-with-error.snapshot.js'
 } 1`] = `
 
-[1] .redocly.yaml:5:3 at #/rules/context
+[1] .redocly.yaml:6:3 at #/rules/context
 
 Property \`context\` is not expected here.
 
-3 |     root: ./openapi.yaml
-4 | rules:
-5 |   context: null
-  |   ^^^^^^^
-6 | extends:
-7 |   - recommended
+4 |
+5 | rules:
+6 |   context:
+  |   ^^^^^^^^
+7 |
+8 | extends:
 
 Error was generated by the configuration spec rule.
 
diff --git a/__tests__/lint-config/invalid-definition-and-config/config-with-warn.snapshot.js b/__tests__/lint-config/invalid-definition-and-config/config-with-warn.snapshot.js
index bc984de8..f954d099 100644
--- a/__tests__/lint-config/invalid-definition-and-config/config-with-warn.snapshot.js
+++ b/__tests__/lint-config/invalid-definition-and-config/config-with-warn.snapshot.js
@@ -6,16 +6,16 @@ exports[`E2E lint-config invalid-definition-and-config: {
   snapshot: 'config-with-warn.snapshot.js'
 } 1`] = `
 
-[1] .redocly.yaml:5:3 at #/rules/context
+[1] .redocly.yaml:6:3 at #/rules/context
 
 Property \`context\` is not expected here.
 
-3 |     root: ./openapi.yaml
-4 | rules:
-5 |   context: null
-  |   ^^^^^^^
-6 | extends:
-7 |   - recommended
+4 |
+5 | rules:
+6 |   context:
+  |   ^^^^^^^^
+7 |
+8 | extends:
 
 Warning was generated by the configuration spec rule.
 
diff --git a/__tests__/lint/deprecated-apiDefinitions/snapshot.js b/__tests__/lint/deprecated-apiDefinitions/snapshot.js
index 5e007ced..d5505a7f 100644
--- a/__tests__/lint/deprecated-apiDefinitions/snapshot.js
+++ b/__tests__/lint/deprecated-apiDefinitions/snapshot.js
@@ -9,23 +9,23 @@ Property \`apiDefinitions\` is not expected here.
 1 | apiDefinitions:
   | ^^^^^^^^^^^^^^
 2 |   main: ./openapi.yaml
-3 | lint:
+3 |
 
 Warning was generated by the configuration spec rule.
 
 
-[2] redocly.yaml:3:1 at #/lint
+[2] redocly.yaml:4:1 at #/lint
 
 Property \`lint\` is not expected here.
 
 Did you mean: env ?
 
-1 | apiDefinitions:
 2 |   main: ./openapi.yaml
-3 | lint:
+3 |
+4 | lint:
   | ^^^^
-4 |   rules:
-5 |     info-contact: warn
+5 |   rules:
+6 |     info-contact: warn
 
 Warning was generated by the configuration spec rule.
 
diff --git a/__tests__/lint/deprecated-lint/snapshot.js b/__tests__/lint/deprecated-lint/snapshot.js
index 3ac90043..b04ab316 100644
--- a/__tests__/lint/deprecated-lint/snapshot.js
+++ b/__tests__/lint/deprecated-lint/snapshot.js
@@ -2,23 +2,39 @@
 
 exports[`E2E lint deprecated-lint 1`] = `
 
-[1] redocly.yaml:7:1 at #/lint
+[1] redocly.yaml:8:1 at #/lint
 
 Property \`lint\` is not expected here.
 
 Did you mean: env ?
 
-5 |       rules:
-6 |         operation-4xx-response: error
-7 | lint:
-  | ^^^^
-8 |   rules:
-9 |     info-contact: warn
+ 6 |         operation-4xx-response: error
+ 7 |
+ 8 | lint:
+   | ^^^^
+ 9 |   rules:
+10 |     info-contact: warn
+
+Warning was generated by the configuration spec rule.
+
+
+[2] redocly.yaml:4:5 at #/apis/api@legacy/lint
+
+Property \`lint\` is not expected here.
+
+Did you mean: root ?
+
+2 | api@legacy:
+3 |   root: ./openapi.yaml
+4 |   lint:
+  |   ^^^^
+5 |     rules:
+6 |       operation-4xx-response: error
 
 Warning was generated by the configuration spec rule.
 
 
-⚠️ Your config has 1 warning.
+⚠️ Your config has 2 warnings.
 The 'lint' field is deprecated. Read more about this change: https://redocly.com/docs/api-registry/guides/migration-guide-config-file/#changed-properties
 validating /openapi.yaml...
 [1] openapi.yaml:11:7 at #/paths/~1pet~1findByStatus/get/responses
diff --git a/__tests__/lint/deprecated-styleguide/snapshot.js b/__tests__/lint/deprecated-styleguide/snapshot.js
index ea88d190..06227399 100644
--- a/__tests__/lint/deprecated-styleguide/snapshot.js
+++ b/__tests__/lint/deprecated-styleguide/snapshot.js
@@ -2,21 +2,35 @@
 
 exports[`E2E lint deprecated-styleguide 1`] = `
 
-[1] redocly.yaml:7:1 at #/styleguide
+[1] redocly.yaml:8:1 at #/styleguide
 
 Property \`styleguide\` is not expected here.
 
-5 |       rules:
-6 |         operation-4xx-response: error
-7 | styleguide:
-  | ^^^^^^^^^^
-8 |   rules:
-9 |     info-contact: warn
+ 6 |         operation-4xx-response: error
+ 7 |
+ 8 | styleguide:
+   | ^^^^^^^^^^
+ 9 |   rules:
+10 |     info-contact: warn
 
 Warning was generated by the configuration spec rule.
 
 
-⚠️ Your config has 1 warning.
+[2] redocly.yaml:4:5 at #/apis/api@legacy/styleguide
+
+Property \`styleguide\` is not expected here.
+
+2 | api@legacy:
+3 |   root: ./openapi.yaml
+4 |   styleguide:
+  |   ^^^^^^^^^^
+5 |     rules:
+6 |       operation-4xx-response: error
+
+Warning was generated by the configuration spec rule.
+
+
+⚠️ Your config has 2 warnings.
 The 'styleguide' field is deprecated. Read more about this change: https://redocly.com/docs/api-registry/guides/migration-guide-config-file/#changed-properties
 validating /openapi.yaml...
 [1] openapi.yaml:11:7 at #/paths/~1pet~1findByStatus/get/responses
diff --git a/packages/core/src/__tests__/lint.test.ts b/packages/core/src/__tests__/lint.test.ts
index 4c7bbec6..dc097f4a 100644
--- a/packages/core/src/__tests__/lint.test.ts
+++ b/packages/core/src/__tests__/lint.test.ts
@@ -22,7 +22,7 @@ describe('lint', () => {
         servers:
           - url: http://redocly-example.com
         paths: {}
-    `,
+      `,
       config: await loadConfig(),
     });
 
@@ -139,8 +139,8 @@ describe('lint', () => {
           "ruleId": "configuration spec",
           "severity": "error",
           "suggest": [
-            "theme",
             "env",
+            "theme",
             "seo",
             "sso",
           ],
diff --git a/packages/core/src/config/__tests__/fixtures/resolve-refs-in-config/config-with-refs.yaml b/packages/core/src/config/__tests__/fixtures/resolve-refs-in-config/config-with-refs.yaml
new file mode 100644
index 00000000..c97410c3
--- /dev/null
+++ b/packages/core/src/config/__tests__/fixtures/resolve-refs-in-config/config-with-refs.yaml
@@ -0,0 +1,8 @@
+rules:
+  $ref: rules.yaml
+
+seo:
+  $ref: seo.yaml
+
+theme:
+  $ref: wrong-ref.yaml
diff --git a/packages/core/src/config/__tests__/fixtures/resolve-refs-in-config/rules.yaml b/packages/core/src/config/__tests__/fixtures/resolve-refs-in-config/rules.yaml
new file mode 100644
index 00000000..40869865
--- /dev/null
+++ b/packages/core/src/config/__tests__/fixtures/resolve-refs-in-config/rules.yaml
@@ -0,0 +1,2 @@
+info-license: error
+non-existing-rule: warn # fail
diff --git a/packages/core/src/config/__tests__/fixtures/resolve-refs-in-config/seo.yaml b/packages/core/src/config/__tests__/fixtures/resolve-refs-in-config/seo.yaml
new file mode 100644
index 00000000..70978a68
--- /dev/null
+++ b/packages/core/src/config/__tests__/fixtures/resolve-refs-in-config/seo.yaml
@@ -0,0 +1,1 @@
+title: 1 # fail
diff --git a/packages/core/src/config/__tests__/load.test.ts b/packages/core/src/config/__tests__/load.test.ts
index c6033f2c..a22ca62f 100644
--- a/packages/core/src/config/__tests__/load.test.ts
+++ b/packages/core/src/config/__tests__/load.test.ts
@@ -1,7 +1,10 @@
 import { loadConfig, findConfig, getConfig, createConfig } from '../load';
 import { RedoclyClient } from '../../redocly';
-import { RuleConfig, FlatRawConfig } from './../types';
 import { Config } from '../config';
+import { lintConfig } from '../../lint';
+import { replaceSourceWithRef } from '../../../__tests__/utils';
+import type { RuleConfig, FlatRawConfig } from './../types';
+import type { NormalizedProblem } from '../../walk';
 
 const fs = require('fs');
 const path = require('path');
@@ -91,6 +94,82 @@ describe('getConfig', () => {
   it('should return empty object if there is no configPath and config file is not found', () => {
     expect(getConfig()).toEqual(Promise.resolve({}));
   });
+
+  it('should resolve refs in config', async () => {
+    let problems: NormalizedProblem[];
+    const result = await getConfig({
+      configPath: path.join(__dirname, './fixtures/resolve-refs-in-config/config-with-refs.yaml'),
+      processRawConfig: async (config, resolvedRefMap) => {
+        problems = await lintConfig({
+          document: config,
+          severity: 'warn',
+          resolvedRefMap,
+        });
+      },
+    });
+    expect(result).toEqual({
+      seo: {
+        title: 1,
+      },
+      styleguide: {
+        rules: {
+          'info-license': 'error',
+          'non-existing-rule': 'warn',
+        },
+      },
+    });
+    expect(replaceSourceWithRef(problems!, __dirname)).toMatchInlineSnapshot(`
+      [
+        {
+          "from": {
+            "pointer": "#/seo",
+            "source": "fixtures/resolve-refs-in-config/config-with-refs.yaml",
+          },
+          "location": [
+            {
+              "pointer": "#/title",
+              "reportOnKey": false,
+              "source": "fixtures/resolve-refs-in-config/seo.yaml",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "warn",
+          "suggest": [],
+        },
+        {
+          "from": {
+            "pointer": "#/rules",
+            "source": "fixtures/resolve-refs-in-config/config-with-refs.yaml",
+          },
+          "location": [
+            {
+              "pointer": "#/non-existing-rule",
+              "reportOnKey": true,
+              "source": "fixtures/resolve-refs-in-config/rules.yaml",
+            },
+          ],
+          "message": "Property \`non-existing-rule\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "warn",
+          "suggest": [],
+        },
+        {
+          "location": [
+            {
+              "pointer": "#/theme",
+              "reportOnKey": false,
+              "source": "fixtures/resolve-refs-in-config/config-with-refs.yaml",
+            },
+          ],
+          "message": "Can't resolve $ref: ENOENT: no such file or directory 'fixtures/resolve-refs-in-config/wrong-ref.yaml'",
+          "ruleId": "configuration no-unresolved-refs",
+          "severity": "warn",
+          "suggest": [],
+        },
+      ]
+    `);
+  });
 });
 
 describe('createConfig', () => {