Repository: Redocly/redocly-cli
Pull request #1401 (2024-01-20 16:04:07)

chore: implement JSON Schema to NodeType adapter
## What/Why/How?

Implemented JSON Schema to NodeType adapter.

This streamlines merging external config schemas while allowing deriving  TypeScript types from JSON Schema schemas.



The adapter itself is [here](https://github.com/Redocly/redocly-cli/pull/1401/files#diff-c4d554b08b03755267606a207150f2f1d9d293aed5875e65e1d6137f1a44986eR1). It uses AJV to determine the correct `oneOf` schema if there's no `discriminator` defined.

The adapter is limited to JSON Schemas that are not booleans or arrays themselves, nor types are arrays.
Also, the schemas should not contain `allOf` or `anyOf` (we're not using them anyway).
~The most crucial limitation is that an array's `items` could not contain `oneOf`. I even had to change the [adapters schema](https://github.com/Redocly/redocly-cli/pull/1401/files#diff-60824f92690be457bba7302950d7eba603e6580fd3850df3e49f2e6f778146b1R275) schema of the `developerOnboarding` section. That is because `NodeType` cannot alter items (like it can do with  `properties` or `additionalProperties`).~ -- Upd: fixed in https://github.com/Redocly/redocly-cli/pull/1401/commits/2fe3a1ce8fd46a4f1e0d7d28cd74d186504df3de

---

I also fixed a wrong snapshot test, added missing configurable rules subject types, and updated the config schema to the latest version.

## Reference

## Testing

### Performance

Investigated performance losses. It doesn't do that bad on linting. For the current Rebilly API description it doesn't show any significant difference comparing to other versions:

| Command | Mean [s] | Min [s] | Max [s] | Relative |
|:---|---:|---:|---:|---:|
| `node node_modules/cli-140/bin/cli.js lint api-definitions/openapi/openapi.yaml` | 1.471 ± 0.035 | 1.431 | 1.554 | 1.00 ± 0.03 |
| `node node_modules/cli-150/bin/cli.js lint api-definitions/openapi/openapi.yaml` | 1.470 ± 0.022 | 1.430 | 1.501 | 1.00 ± 0.02 |
| `node node_modules/cli-160/bin/cli.js lint api-definitions/openapi/openapi.yaml` | 1.468 ± 0.017 | 1.454 | 1.511 | 1.00 |
| `node node_modules/cli-170/bin/cli.js lint api-definitions/openapi/openapi.yaml` | 1.514 ± 0.023 | 1.481 | 1.547 | 1.03 ± 0.02 |
| `node node_modules/cli-180/bin/cli.js lint api-definitions/openapi/openapi.yaml` | 1.492 ± 0.020 | 1.452 | 1.514 | 1.02 ± 0.02 |
| `node node_modules/cli-next/bin/cli.js lint api-definitions/openapi/openapi.yaml` | 1.496 ± 0.025 | 1.464 | 1.537 | 1.02 ± 0.02 |

On the other hand, for smaller museum.yaml API description the losses are higher (which most likely means the config itself gets resolved longer):

| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `node node_modules/cli-140/bin/cli.js lint ../resources/museum.yaml` | 469.3 ± 11.5 | 457.2 | 491.4 | 1.00 |
| `node node_modules/cli-150/bin/cli.js lint ../resources/museum.yaml` | 472.4 ± 5.9 | 464.5 | 482.1 | 1.01 ± 0.03 |
| `node node_modules/cli-160/bin/cli.js lint ../resources/museum.yaml` | 479.0 ± 13.3 | 467.3 | 504.1 | 1.02 ± 0.04 |
| `node node_modules/cli-170/bin/cli.js lint ../resources/museum.yaml` | 473.1 ± 5.0 | 467.2 | 482.0 | 1.01 ± 0.03 |
| `node node_modules/cli-180/bin/cli.js lint ../resources/museum.yaml` | 485.0 ± 4.0 | 477.5 | [489](https://github.com/Redocly/redocly-cli/actions/runs/7883556561/job/21510885819#step:6:490).3 | 1.03 ± 0.03 |
| `node node_modules/cli-next/bin/cli.js lint ../resources/museum.yaml` | [490](https://github.com/Redocly/redocly-cli/actions/runs/7883556561/job/21510885819#step:6:491).0 ± 3.2 | 485.1 | 495.6 | 1.04 ± 0.03 |

## 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: 43242ac9cf48110a267e9b5bc5fa4e90ae2d9af2

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/json-schema-to-node-type-adapter?filename=.changeset/gold-mugs-glow.md&value=---%0A%22%40redocly%2Fopenapi-core%22%3A%20patch%0A---%0A%0Achore%3A%20implement%20JSON%20Schema%20to%20NodeType%20adapter%0A)


github-actions[bot]: | Command | Mean [s] | Min [s] | Max [s] | Relative |
|:---|---:|---:|---:|---:|
| `redocly lint packages/core/src/benchmark/benches/rebilly.yaml` | 1.065 ± 0.019 | 1.037 | 1.091 | 1.00 |
| `redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml` | 1.089 ± 0.020 | 1.058 | 1.115 | 1.02 ± 0.03 |

<!-- thollander/actions-comment-pull-request "hyperfine-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 | 75.9%      |    4174/5499    |
|                                                                           🟡                                                                           | Branches   | 66.28%     |    2237/3375    |
|                                                                           🟡                                                                           | Functions  | 68.43%     |     674/985     |
|                                                                           🟡                                                                           | Lines      | 76.12%     |    3917/5146    |

<details><summary>Show new covered files 🐣</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/types/json-schema-adapter.ts">`...` / json-schema-adapter.ts</div> | 71.76%     | 70.42%   | 58.33%    | 72.15% |

</details>


## Test suite run success
671 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 43242ac9cf48110a267e9b5bc5fa4e90ae2d9af2</p>

github-actions[bot]: | Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `redocly lint packages/core/src/benchmark/benches/rebilly.yaml` | 955.7 ± 15.3 | 932.5 | 975.2 | 1.00 |
| `redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml` | 999.2 ± 22.9 | 975.9 | 1043.3 | 1.05 ± 0.03 |

<!-- thollander/actions-comment-pull-request "latest-vs-next-comparison" -->

Patch:
diff --git a/package-lock.json b/package-lock.json
index 046a21a9..bab7eedc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
         "eslint": "^8.22.0",
         "jest": "^29.0.0",
         "jest-environment-jsdom": "^29.7.0",
+        "json-schema-to-ts": "^3.0.0",
         "null-loader": "^4.0.0",
         "outdent": "^0.7.1",
         "prettier": "^2.1.2",
@@ -8527,6 +8528,20 @@
         "foreach": "^2.0.4"
       }
     },
+    "node_modules/json-schema-to-ts": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.0.0.tgz",
+      "integrity": "sha512-2adDesYifYEXYxNySx3gG0RR69rDWIjqAFzK/JPXdOvjHLZ/UP6d2rkpy6a+AxyhtRp2SvFPZ4+EW36jBinUbA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/runtime": "^7.18.3",
+        "@types/json-schema": "^7.0.9",
+        "ts-algebra": "^1.2.2"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
     "node_modules/json-schema-traverse": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -11748,6 +11763,12 @@
         "url": "https://github.com/sponsors/wooorm"
       }
     },
+    "node_modules/ts-algebra": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-1.2.2.tgz",
+      "integrity": "sha512-kloPhf1hq3JbCPOTYoOWDKxebWjNb2o/LKnNfkWhxVVisFFmMJPPdJeGoGmM+iRLyoXAR61e08Pb+vUXINg8aA==",
+      "dev": true
+    },
     "node_modules/ts-api-utils": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz",
@@ -19420,6 +19441,17 @@
         "foreach": "^2.0.4"
       }
     },
+    "json-schema-to-ts": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.0.0.tgz",
+      "integrity": "sha512-2adDesYifYEXYxNySx3gG0RR69rDWIjqAFzK/JPXdOvjHLZ/UP6d2rkpy6a+AxyhtRp2SvFPZ4+EW36jBinUbA==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.18.3",
+        "@types/json-schema": "^7.0.9",
+        "ts-algebra": "^1.2.2"
+      }
+    },
     "json-schema-traverse": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -21803,6 +21835,12 @@
       "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==",
       "dev": true
     },
+    "ts-algebra": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-1.2.2.tgz",
+      "integrity": "sha512-kloPhf1hq3JbCPOTYoOWDKxebWjNb2o/LKnNfkWhxVVisFFmMJPPdJeGoGmM+iRLyoXAR61e08Pb+vUXINg8aA==",
+      "dev": true
+    },
     "ts-api-utils": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz",
diff --git a/package.json b/package.json
index c74dcbb1..e960a067 100644
--- a/package.json
+++ b/package.json
@@ -68,6 +68,7 @@
     "eslint": "^8.22.0",
     "jest": "^29.0.0",
     "jest-environment-jsdom": "^29.7.0",
+    "json-schema-to-ts": "^3.0.0",
     "null-loader": "^4.0.0",
     "outdent": "^0.7.1",
     "prettier": "^2.1.2",
diff --git a/packages/core/src/decorators/oas2/remove-unused-components.ts b/packages/core/src/decorators/oas2/remove-unused-components.ts
index 406a2966..62975f46 100644
--- a/packages/core/src/decorators/oas2/remove-unused-components.ts
+++ b/packages/core/src/decorators/oas2/remove-unused-components.ts
@@ -1,8 +1,9 @@
-import type { Oas2Decorator } from '../../visitors';
 import { Location } from '../../ref-utils';
-import type { Oas2Components } from '../../typings/swagger';
 import { isEmptyObject } from '../../utils';
 
+import type { Oas2Decorator } from '../../visitors';
+import type { Oas2Components } from '../../typings/swagger';
+
 export const RemoveUnusedComponents: Oas2Decorator = () => {
   const components = new Map<
     string,
diff --git a/packages/core/src/decorators/oas3/remove-unused-components.ts b/packages/core/src/decorators/oas3/remove-unused-components.ts
index 8fc9cfef..f71e1b67 100644
--- a/packages/core/src/decorators/oas3/remove-unused-components.ts
+++ b/packages/core/src/decorators/oas3/remove-unused-components.ts
@@ -1,8 +1,9 @@
-import type { Oas3Decorator } from '../../visitors';
 import { Location } from '../../ref-utils';
-import type { Oas3Components } from '../../typings/openapi';
 import { isEmptyObject } from '../../utils';
 
+import type { Oas3Decorator } from '../../visitors';
+import type { Oas3Components } from '../../typings/openapi';
+
 export const RemoveUnusedComponents: Oas3Decorator = () => {
   const components = new Map<
     string,
diff --git a/packages/core/src/lint.ts b/packages/core/src/lint.ts
index f6a93b63..ceff0a97 100755
--- a/packages/core/src/lint.ts
+++ b/packages/core/src/lint.ts
@@ -112,6 +112,7 @@ export async function lintConfig(opts: {
   resolvedRefMap?: ResolvedRefMap;
   severity?: ProblemSeverity;
   externalRefResolver?: BaseResolver;
+  externalConfigTypes?: Record<string, NodeType>;
 }) {
   const { document, severity, externalRefResolver = new BaseResolver() } = opts;
 
@@ -126,7 +127,7 @@ export async function lintConfig(opts: {
     rules: { spec: 'error' },
   });
 
-  const types = normalizeTypes(ConfigTypes, config);
+  const types = normalizeTypes(opts.externalConfigTypes || ConfigTypes, config);
   const rules: (RuleInstanceConfig & {
     visitor: NestedVisitObject<unknown, Oas3Visitor | Oas3Visitor[]>;
   })[] = [
diff --git a/packages/core/src/resolve.ts b/packages/core/src/resolve.ts
index ce9979a6..7093b4f9 100644
--- a/packages/core/src/resolve.ts
+++ b/packages/core/src/resolve.ts
@@ -269,8 +269,20 @@ export async function resolveDocument(opts: {
         if (itemsType === undefined && type !== unknownType && type !== SpecExtension) {
           return;
         }
+        const isTypeAFunction = typeof itemsType === 'function';
         for (let i = 0; i < node.length; i++) {
-          walk(node[i], itemsType || unknownType, joinPointer(nodeAbsoluteRef, i));
+          const itemType = isTypeAFunction
+            ? itemsType(node[i], joinPointer(nodeAbsoluteRef, i))
+            : itemsType;
+          // we continue resolving unknown types, but stop early on known scalars
+          if (itemType === undefined && type !== unknownType && type !== SpecExtension) {
+            continue;
+          }
+          walk(
+            node[i],
+            isNamedType(itemType) ? itemType : unknownType,
+            joinPointer(nodeAbsoluteRef, i)
+          );
         }
         return;
       }
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index e3406726..0ab0ed52 100755
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -21,31 +21,28 @@ export type NormalizedScalarSchema = {
 export type NodeType = {
   properties: Record<string, PropType | ResolveTypeFn>;
   additionalProperties?: PropType | ResolveTypeFn;
-  items?: string;
+  items?: PropType | ResolveTypeFn;
   required?: string[] | ((value: any, key: string | number | undefined) => string[]);
   requiredOneOf?: string[];
   allowed?: (value: any) => string[] | undefined;
   extensionsPrefix?: string;
 };
-type PropType = string | NodeType | ScalarSchema | undefined | null;
-type ResolveTypeFn = (value: any, key: string) => string | PropType;
+export type PropType = string | NodeType | ScalarSchema | undefined | null;
+export type ResolveTypeFn = (value: any, key: string) => string | PropType;
 
 export type NormalizedNodeType = {
   name: string;
   properties: Record<string, NormalizedPropType | NormalizedResolveTypeFn>;
   additionalProperties?: NormalizedPropType | NormalizedResolveTypeFn;
-  items?: NormalizedNodeType;
+  items?: NormalizedPropType | NormalizedResolveTypeFn;
   required?: string[] | ((value: any, key: string | number | undefined) => string[]);
   requiredOneOf?: string[];
   allowed?: (value: any) => string[] | undefined;
   extensionsPrefix?: string;
 };
 
-type NormalizedPropType = NormalizedNodeType | NormalizedScalarSchema | undefined | null;
-type NormalizedResolveTypeFn = (
-  value: any,
-  key: string
-) => NormalizedNodeType | NormalizedScalarSchema | undefined | null;
+type NormalizedPropType = NormalizedNodeType | NormalizedScalarSchema | null | undefined;
+type NormalizedResolveTypeFn = (value: any, key: string) => NormalizedPropType;
 
 export function listOf(typeName: string) {
   return {
@@ -142,8 +139,6 @@ export function normalizeTypes(
   }
 }
 
-export function isNamedType(
-  t: NormalizedNodeType | NormalizedScalarSchema | null | undefined
-): t is NormalizedNodeType {
+export function isNamedType(t: NormalizedPropType): t is NormalizedNodeType {
   return typeof t?.name === 'string';
 }
diff --git a/packages/core/src/types/json-schema-adapter.ts b/packages/core/src/types/json-schema-adapter.ts
new file mode 100644
index 00000000..daa23fec
--- /dev/null
+++ b/packages/core/src/types/json-schema-adapter.ts
@@ -0,0 +1,217 @@
+// For internal usage only
+
+import Ajv from '@redocly/ajv/dist/2020';
+import { isPlainObject } from '../utils';
+
+import type { NodeType, PropType, ResolveTypeFn } from '.';
+import type { JSONSchema } from 'json-schema-to-ts';
+import { Oas3Schema } from '../typings/openapi';
+
+const ajv = new Ajv({
+  strictSchema: false,
+  allowUnionTypes: true,
+  useDefaults: true,
+  allErrors: true,
+  discriminator: true,
+  strictTypes: false,
+  verbose: true,
+});
+
+function findOneOf(schemaOneOf: JSONSchema[], oneOfs: (PropType | ResolveTypeFn)[]): ResolveTypeFn {
+  if (oneOfs.some((option) => typeof option === 'function')) {
+    throw new Error('Unexpected oneOf inside oneOf.');
+  }
+
+  return (value: unknown) => {
+    let index = schemaOneOf.findIndex((option) => ajv.validate(option, value));
+    if (index === -1) {
+      index = 0;
+    }
+    return oneOfs[index] as PropType;
+  };
+}
+
+function transformJSONSchemaToNodeType(
+  propertyName: string,
+  schema: JSONSchema,
+  ctx: Record<string, NodeType>
+): PropType | ResolveTypeFn {
+  if (!schema || typeof schema === 'boolean') {
+    throw new Error(`Unexpected schema in ${propertyName}.`);
+  }
+
+  if (schema instanceof Array) {
+    throw new Error(`Unexpected array schema in ${propertyName}. Try using oneOf instead.`);
+  }
+
+  if (schema.type === 'null') {
+    throw new Error(`Unexpected null schema type in ${propertyName} schema.`);
+  }
+
+  if (schema.type instanceof Array) {
+    throw new Error(
+      `Unexpected array schema type in ${propertyName} schema. Try using oneOf instead.`
+    );
+  }
+
+  if (
+    schema.type === 'string' ||
+    schema.type === 'number' ||
+    schema.type === 'integer' ||
+    schema.type === 'boolean'
+  ) {
+    const { default: _, format: _format, ...rest } = schema;
+    return rest as PropType;
+  }
+
+  if (schema.type === 'object' && !schema.properties && !schema.oneOf) {
+    if (schema.additionalProperties === undefined || schema.additionalProperties === true) {
+      return { type: 'object' };
+    } else if (schema.additionalProperties === false) {
+      return { type: 'object', properties: {} };
+    }
+  }
+
+  if (schema.allOf) {
+    throw new Error(`Unexpected allOf in ${propertyName}.`);
+  }
+
+  if (schema.anyOf) {
+    throw new Error(`Unexpected anyOf in ${propertyName}.`);
+  }
+
+  if (
+    isPlainObject(schema.properties) ||
+    isPlainObject(schema.additionalProperties) ||
+    (isPlainObject(schema.items) &&
+      (isPlainObject(schema.items.properties) ||
+        isPlainObject(schema.items.additionalProperties) ||
+        schema.items.oneOf)) // exclude scalar array types
+  ) {
+    return extractNodeToContext(propertyName, schema, ctx);
+  }
+
+  if (schema.oneOf) {
+    if ((schema as Oas3Schema).discriminator) {
+      const discriminatedPropertyName = (schema as Oas3Schema).discriminator?.propertyName;
+      if (!discriminatedPropertyName) {
+        throw new Error(`Unexpected discriminator without a propertyName in ${propertyName}.`);
+      }
+      const oneOfs = schema.oneOf.map((option, i) => {
+        if (typeof option === 'boolean') {
+          throw new Error(
+            `Unexpected boolean schema in ${propertyName} at position ${i} in oneOf.`
+          );
+        }
+        const discriminatedProperty = option?.properties?.[discriminatedPropertyName];
+        if (!discriminatedProperty || typeof discriminatedProperty === 'boolean') {
+          throw new Error(
+            `Unexpected property '${discriminatedProperty}' schema in ${propertyName} at position ${i} in oneOf.`
+          );
+        }
+        const name = discriminatedProperty.const as string;
+        return transformJSONSchemaToNodeType(name, option, ctx);
+      });
+
+      return (value: unknown, key: string) => {
+        if (isPlainObject(value)) {
+          const discriminatedTypeName = value[discriminatedPropertyName];
+          if (typeof discriminatedTypeName === 'string' && ctx[discriminatedTypeName]) {
+            return discriminatedTypeName;
+          }
+        }
+        return findOneOf(schema.oneOf as JSONSchema[], oneOfs)(value, key);
+      };
+    } else {
+      const oneOfs = schema.oneOf.map((option, i) =>
+        transformJSONSchemaToNodeType(propertyName + '_' + i, option, ctx)
+      );
+      return findOneOf(schema.oneOf as JSONSchema[], oneOfs);
+    }
+  }
+
+  return schema as PropType;
+}
+
+function extractNodeToContext(
+  propertyName: string,
+  schema: JSONSchema,
+  ctx: Record<string, NodeType>
+): string {
+  if (!schema || typeof schema === 'boolean') {
+    throw new Error(`Unexpected schema in ${propertyName}.`);
+  }
+
+  if (schema instanceof Array) {
+    throw new Error(`Unexpected array schema in ${propertyName}. Try using oneOf instead.`);
+  }
+
+  if (schema.type === 'null') {
+    throw new Error(`Unexpected null schema type in ${propertyName} schema.`);
+  }
+
+  if (schema.type instanceof Array) {
+    throw new Error(
+      `Unexpected array schema type in ${propertyName} schema. Try using oneOf instead.`
+    );
+  }
+
+  const properties: Record<string, PropType | ResolveTypeFn> = {};
+  for (const [name, property] of Object.entries(schema.properties || {})) {
+    properties[name] = transformJSONSchemaToNodeType(propertyName + '.' + name, property, ctx);
+  }
+
+  let additionalProperties;
+  if (isPlainObject(schema.additionalProperties)) {
+    additionalProperties = transformJSONSchemaToNodeType(
+      propertyName + '_additionalProperties',
+      schema.additionalProperties,
+      ctx
+    );
+  }
+  if (schema.additionalProperties === true) {
+    additionalProperties = {};
+  }
+
+  let items;
+  if (
+    isPlainObject(schema.items) &&
+    (isPlainObject(schema.items.properties) ||
+      isPlainObject(schema.items.additionalProperties) ||
+      schema.items.oneOf) // exclude scalar array types
+  ) {
+    items = transformJSONSchemaToNodeType(propertyName + '_items', schema.items, ctx);
+  }
+
+  let required = schema.required as NodeType['required'];
+  // Translate required in oneOfs into a ResolveTypeFn.
+  if (schema.oneOf && schema.oneOf.every((option) => !!(option as Oas3Schema).required)) {
+    required = (value): string[] => {
+      const requiredList: string[][] = schema.oneOf!.map((option) => [
+        ...(schema.required || []),
+        ...(option as Oas3Schema).required!,
+      ]);
+
+      let index = requiredList.findIndex((r) =>
+        r.every((requiredProp) => value[requiredProp] !== undefined)
+      );
+      if (index === -1) {
+        index = 0;
+      }
+
+      return requiredList[index];
+    };
+  }
+
+  ctx[propertyName] = { properties, additionalProperties, items, required };
+  return propertyName;
+}
+
+export function getNodeTypesFromJSONSchema(
+  schemaName: string,
+  entrySchema: JSONSchema
+): Record<string, NodeType> {
+  const ctx: Record<string, NodeType> = {};
+  transformJSONSchemaToNodeType(schemaName, entrySchema, ctx);
+  return ctx;
+}
diff --git a/packages/core/src/types/oas2.ts b/packages/core/src/types/oas2.ts
index 8682c8b8..7fb33052 100644
--- a/packages/core/src/types/oas2.ts
+++ b/packages/core/src/types/oas2.ts
@@ -1,4 +1,7 @@
-import { NodeType, listOf, mapOf } from '.';
+import { listOf, mapOf } from '.';
+
+import type { NodeType } from '.';
+import type { Oas2NodeType } from './redocly-yaml';
 
 const responseCodeRegexp = /^[0-9][0-9Xx]{2}$/;
 
@@ -437,7 +440,7 @@ const Example: NodeType = {
   extensionsPrefix: 'x-',
 };
 
-export const Oas2Types: Record<string, NodeType> = {
+export const Oas2Types: Record<Oas2NodeType, NodeType> = {
   Root,
   Tag,
   TagList: listOf('Tag'),
diff --git a/packages/core/src/types/oas3.ts b/packages/core/src/types/oas3.ts
index 58358b1f..e51ece0d 100755
--- a/packages/core/src/types/oas3.ts
+++ b/packages/core/src/types/oas3.ts
@@ -1,5 +1,9 @@
-import { NodeType, listOf, mapOf } from '.';
+import { listOf, mapOf } from '.';
 import { isMappingRef } from '../ref-utils';
+
+import type { NodeType } from '.';
+import type { Oas3NodeType } from './redocly-yaml';
+
 const responseCodeRegexp = /^[0-9][0-9Xx]{2}$/;
 
 const Root: NodeType = {
@@ -531,7 +535,7 @@ const XUsePkce: NodeType = {
   },
 };
 
-export const Oas3Types: Record<string, NodeType> = {
+export const Oas3Types: Record<Oas3NodeType, NodeType> = {
   Root,
   Tag,
   TagList: listOf('Tag'),
diff --git a/packages/core/src/types/oas3_1.ts b/packages/core/src/types/oas3_1.ts
index 9bde3f6f..059a2223 100755
--- a/packages/core/src/types/oas3_1.ts
+++ b/packages/core/src/types/oas3_1.ts
@@ -1,6 +1,9 @@
-import { NodeType, listOf, mapOf } from '.';
+import { listOf, mapOf } from '.';
 import { Oas3Types } from './oas3';
 
+import type { NodeType } from '.';
+import type { Oas3_1NodeType } from './redocly-yaml';
+
 const Root: NodeType = {
   properties: {
     openapi: null,
@@ -262,7 +265,7 @@ const SecurityScheme: NodeType = {
   extensionsPrefix: 'x-',
 };
 
-export const Oas3_1Types: Record<string, NodeType> = {
+export const Oas3_1Types: Record<Oas3_1NodeType, NodeType> = {
   ...Oas3Types,
   Info,
   Root,
diff --git a/packages/core/src/types/portal-config-schema.ts b/packages/core/src/types/portal-config-schema.ts
index e48e6719..ab1e2b2d 100644
--- a/packages/core/src/types/portal-config-schema.ts
+++ b/packages/core/src/types/portal-config-schema.ts
@@ -3,8 +3,10 @@ import {
   AuthProviderType,
   DEFAULT_TEAM_CLAIM_NAME,
 } from '../config';
+import { themeConfigSchema } from './theme-config';
 
-import type { NodeType } from '.';
+import type { FromSchema } from 'json-schema-to-ts';
+import type { ThemeConfig } from './theme-config';
 
 const oidcIssuerMetadataSchema = {
   type: 'object',
@@ -23,10 +25,11 @@ const oidcProviderConfigSchema = {
   properties: {
     type: { type: 'string', const: AuthProviderType.OIDC },
     title: { type: 'string' },
+    pkce: { type: 'boolean', default: false },
     configurationUrl: { type: 'string', minLength: 1 },
     configuration: oidcIssuerMetadataSchema,
     clientId: { type: 'string', minLength: 1 },
-    clientSecret: { type: 'string', minLength: 1 },
+    clientSecret: { type: 'string', minLength: 0 },
     teamsClaimName: { type: 'string' },
     teamsClaimMap: { type: 'object', additionalProperties: { type: 'string' } },
     defaultTeams: { type: 'array', items: { type: 'string' } },
@@ -36,7 +39,7 @@ const oidcProviderConfigSchema = {
     tokenRequestCustomParams: { type: 'object', additionalProperties: { type: 'string' } },
     audience: { type: 'array', items: { type: 'string' } },
   },
-  required: ['type', 'clientId', 'clientSecret'],
+  required: ['type', 'clientId'],
   oneOf: [{ required: ['configurationUrl'] }, { required: ['configuration'] }],
   additionalProperties: false,
 } as const;
@@ -87,11 +90,27 @@ const authProviderConfigSchema = {
   discriminator: { propertyName: 'type' },
 } as const;
 
-export const ssoConfigSchema = {
+const ssoOnPremConfigSchema = {
   type: 'object',
-  properties: {},
   additionalProperties: authProviderConfigSchema,
-} as NodeType;
+} as const;
+
+const ssoConfigSchema = {
+  oneOf: [
+    {
+      type: 'array',
+      items: {
+        type: 'string',
+        enum: ['REDOCLY', 'CORPORATE', 'GUEST'],
+      },
+      uniqueItems: true,
+    },
+    {
+      type: 'string',
+      enum: ['REDOCLY', 'CORPORATE', 'GUEST'],
+    },
+  ],
+} as const;
 
 const redirectConfigSchema = {
   type: 'object',
@@ -99,32 +118,34 @@ const redirectConfigSchema = {
     to: { type: 'string' },
     type: { type: 'number', default: 301 },
   },
-  required: ['to'],
-} as NodeType;
+  additionalProperties: false,
+} as const;
 
 const redirectsConfigSchema = {
   type: 'object',
-  properties: {},
-  additionalProperties: 'redirectConfigSchema',
+  additionalProperties: redirectConfigSchema,
   default: {},
-} as NodeType;
+} as const;
 
-export const apiConfigSchema = {
+const apiConfigSchema = {
   type: 'object',
   properties: {
     root: { type: 'string' },
+    output: { type: 'string', pattern: '(.ya?ml|.json)$' },
     rbac: { type: 'object', additionalProperties: true },
     theme: {
       type: 'object',
       properties: {
-        openapi: { type: 'object', additionalProperties: true },
+        openapi: themeConfigSchema.properties.openapi,
+        graphql: themeConfigSchema.properties.graphql,
       },
       additionalProperties: false,
     },
     title: { type: 'string' },
     metadata: { type: 'object', additionalProperties: true },
+    rules: { type: 'object', additionalProperties: true },
+    decorators: { type: 'object', additionalProperties: true },
   },
-  additionalProperties: true,
   required: ['root'],
 } as const;
 
@@ -140,7 +161,9 @@ const seoConfigSchema = {
     description: { type: 'string' },
     siteUrl: { type: 'string' },
     image: { type: 'string' },
-    keywords: { type: 'array', items: { type: 'string' } },
+    keywords: {
+      oneOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
+    },
     lang: { type: 'string' },
     jsonLd: { type: 'object' },
     meta: {
@@ -156,21 +179,25 @@ const seoConfigSchema = {
       },
     },
   },
+  additionalProperties: false,
 } as const;
 
-const rbacScopeItemsSchema = {
-  type: 'object',
-  properties: {},
-  additionalProperties: { type: 'string' },
-} as NodeType;
+const rbacScopeItemsSchema = { type: 'object', additionalProperties: { type: 'string' } } as const;
 
 const rbacConfigSchema = {
   type: 'object',
   properties: {
-    defaults: 'rbacScopeItemsSchema',
+    cms: rbacScopeItemsSchema,
+    content: {
+      type: 'object',
+      properties: {
+        '**': rbacScopeItemsSchema,
+      },
+      additionalProperties: rbacScopeItemsSchema,
+    },
   },
   additionalProperties: rbacScopeItemsSchema,
-} as NodeType;
+} as const;
 
 const graviteeAdapterConfigSchema = {
   type: 'object',
@@ -246,13 +273,14 @@ const devOnboardingAdapterConfigSchema = {
 const devOnboardingConfigSchema = {
   type: 'object',
   required: ['adapters'],
+  additionalProperties: false,
   properties: {
     adapters: {
       type: 'array',
       items: devOnboardingAdapterConfigSchema,
     },
   },
-} as NodeType;
+} as const;
 
 const i18ConfigSchema = {
   type: 'object',
@@ -276,8 +304,9 @@ const i18ConfigSchema = {
       },
     },
   },
-  required: ['defaultLocale', 'locales'],
-} as NodeType;
+  additionalProperties: false,
+  required: ['defaultLocale'],
+} as const;
 
 const responseHeaderSchema = {
   type: 'object',
@@ -289,25 +318,13 @@ 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 = {
+const redoclyConfigSchema = {
   type: 'object',
   properties: {
     licenseKey: { type: 'string' },
-    redirects: 'redirectsConfigSchema',
-    seo: 'seoConfigSchema',
-    rbac: 'rbacConfigSchema',
+    redirects: redirectsConfigSchema,
+    seo: seoConfigSchema,
+    rbac: rbacConfigSchema,
     responseHeaders: {
       type: 'object',
       additionalProperties: {
@@ -329,38 +346,71 @@ export const redoclyConfigSchema = {
       type: 'object',
       additionalProperties: apiConfigSchema,
     },
-    sso: 'ssoConfigSchema',
-    developerOnboarding: 'devOnboardingConfigSchema',
-    i18n: 'i18ConfigSchema',
+    ssoOnPrem: ssoOnPremConfigSchema,
+    sso: ssoConfigSchema,
+    residency: { type: 'string' },
+    developerOnboarding: devOnboardingConfigSchema,
+    i18n: i18ConfigSchema,
     metadata: metadataConfigSchema,
-  },
-  default: {},
-} as NodeType;
-
-export const environmentSchema = {
-  oneOf: [
-    { ...redoclyConfigSchema, additionalProperties: false },
-    {
-      type: 'object',
-      properties: {
-        $ref: { type: 'string' },
+    ignore: {
+      type: 'array',
+      items: {
+        type: 'string',
       },
-      required: ['$ref'],
-      additionalProperties: false,
     },
-  ],
+    theme: themeConfigSchema,
+  },
+  default: { redirects: {} },
+  additionalProperties: true,
+} as const;
+
+const environmentSchema = {
+  ...redoclyConfigSchema,
+  additionalProperties: false,
 } as const;
 
 export const rootRedoclyConfigSchema = {
   ...redoclyConfigSchema,
   properties: {
+    plugins: {
+      type: 'array',
+      items: { type: 'string' },
+    },
     ...redoclyConfigSchema.properties,
     env: {
       type: 'object',
-      properties: {},
-      additionalProperties: environmentSchema,
+      additionalProperties: environmentSchema, // TODO: if we want full validation we need to override apis, theme and the root
     },
   },
   default: {},
-  required: ['redirects'],
+  additionalProperties: false,
 } as const;
+
+export type RedoclyConfig<T = ThemeConfig> = FromSchema<typeof rootRedoclyConfigSchema> & {
+  theme?: T;
+};
+export type RedirectConfig = FromSchema<typeof redirectConfigSchema>;
+export type RedirectsConfig = FromSchema<typeof redirectsConfigSchema>;
+
+export type AuthProviderConfig = FromSchema<typeof authProviderConfigSchema>;
+export type BasicAuthProviderConfig = FromSchema<typeof basicAuthProviderConfigSchema>;
+export type OidcProviderConfig = FromSchema<typeof oidcProviderConfigSchema>;
+export type Saml2ProviderConfig = FromSchema<typeof saml2ProviderConfigSchema>;
+export type SeoConfig = FromSchema<typeof seoConfigSchema>;
+export type RbacConfig = FromSchema<typeof rbacConfigSchema>;
+export type RbacScopeItems = FromSchema<typeof rbacScopeItemsSchema>;
+export type OidcIssuerMetadata = FromSchema<typeof oidcIssuerMetadataSchema>;
+
+export type DevOnboardingAdapterConfig = FromSchema<typeof devOnboardingAdapterConfigSchema>;
+export type GraviteeAdapterConfig = FromSchema<typeof graviteeAdapterConfigSchema>;
+export type ApigeeAdapterConfig = FromSchema<
+  typeof apigeeXAdapterConfigSchema | typeof apigeeEdgeAdapterConfigSchema
+>;
+export type ApigeeAdapterAuthOauth2 = FromSchema<typeof apigeeAdapterAuthOauth2Schema>;
+export type ApigeeAdapterAuthServiceAccount = FromSchema<
+  typeof apigeeAdapterAuthServiceAccountSchema
+>;
+export type SsoConfig = FromSchema<typeof ssoOnPremConfigSchema>;
+export type I18nConfig = FromSchema<typeof i18ConfigSchema>;
+
+export type ApiConfig = FromSchema<typeof apiConfigSchema>;
diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts
index 83eac81e..9ef36780 100644
--- a/packages/core/src/types/redocly-yaml.ts
+++ b/packages/core/src/types/redocly-yaml.ts
@@ -1,8 +1,10 @@
-import { rootRedoclyConfigSchema, apiConfigSchema } from './portal-config-schema';
-import { themeConfigSchema } from './theme-config';
-import { NodeType, listOf } from '.';
+import { rootRedoclyConfigSchema } from './portal-config-schema';
+import { listOf } from '.';
 import { omitObjectProps, pickObjectProps, isCustomRuleId } from '../utils';
-import { PortalConfigNodeTypes } from './portal-config-schema';
+import { getNodeTypesFromJSONSchema } from './json-schema-adapter';
+
+import type { NodeType } from '.';
+import type { JSONSchema } from 'json-schema-to-ts';
 
 const builtInCommonRules = [
   'spec',
@@ -97,8 +99,49 @@ const builtInRules = [
 
 type BuiltInRuleId = typeof builtInRules[number];
 
-const nodeTypesList = [
-  'any',
+const oas2NodeTypesList = [
+  'Root',
+  'Tag',
+  'TagList',
+  'ExternalDocs',
+  'SecurityRequirement',
+  'SecurityRequirementList',
+  'Info',
+  'Contact',
+  'License',
+  'Paths',
+  'PathItem',
+  'Parameter',
+  'ParameterList',
+  'ParameterItems',
+  'Operation',
+  'Example',
+  'ExamplesMap',
+  'Examples',
+  'Header',
+  'Responses',
+  'Response',
+  'Schema',
+  'Xml',
+  'SchemaProperties',
+  'NamedSchemas',
+  'NamedResponses',
+  'NamedParameters',
+  'NamedSecuritySchemes',
+  'SecurityScheme',
+  'TagGroup',
+  'TagGroups',
+  'EnumDescriptions',
+  'Logo',
+  'XCodeSample',
+  'XCodeSampleList',
+  'XServer',
+  'XServerList',
+] as const;
+
+export type Oas2NodeType = typeof oas2NodeTypesList[number];
+
+const oas3NodeTypesList = [
   'Root',
   'Tag',
   'TagList',
@@ -153,12 +196,33 @@ const nodeTypesList = [
   'AuthorizationCode',
   'OAuth2Flows',
   'SecurityScheme',
+  'TagGroup',
+  'TagGroups',
+  'EnumDescriptions',
+  'Logo',
   'XCodeSample',
   'XCodeSampleList',
+  'XUsePkce',
   'WebhooksMap',
-  'SpecExtension',
-  'Message',
-];
+] as const;
+
+export type Oas3NodeType = typeof oas3NodeTypesList[number];
+
+const oas3_1NodeTypesList = [
+  'Root',
+  'Schema',
+  'SchemaProperties',
+  'Info',
+  'License',
+  'Components',
+  'NamedPathItems',
+  'SecurityScheme',
+  'Operation',
+] as const;
+
+export type Oas3_1NodeType = typeof oas3_1NodeTypesList[number];
+
+const asyncNodeTypesList = ['Message'] as const;
 
 const ConfigStyleguide: NodeType = {
   properties: {
@@ -186,22 +250,13 @@ const ConfigStyleguide: NodeType = {
   },
 };
 
-const RootConfigStyleguide: NodeType = {
+const createConfigRoot = (nodeTypes: Record<string, NodeType>): NodeType => ({
+  ...nodeTypes.rootRedoclyConfigSchema,
   properties: {
-    plugins: {
-      type: 'array',
-      items: { type: 'string' },
-    },
+    ...nodeTypes.rootRedoclyConfigSchema.properties,
     ...ConfigStyleguide.properties,
-  },
-};
-
-const ConfigRoot: NodeType = {
-  properties: {
-    ...rootRedoclyConfigSchema.properties,
-    ...RootConfigStyleguide.properties,
-    apis: 'ConfigApis',
-    theme: 'ConfigRootTheme',
+    apis: 'ConfigApis', // Override apis with internal format
+    theme: 'ConfigRootTheme', // Override theme with internal format
     'features.openapi': 'ConfigReferenceDocs', // deprecated
     'features.mockServer': 'ConfigMockServer', // deprecated
     organization: { type: 'string' },
@@ -220,17 +275,17 @@ const ConfigRoot: NodeType = {
       },
     },
   },
-};
+});
 
 const ConfigApis: NodeType = {
   properties: {},
   additionalProperties: 'ConfigApisProperties',
 };
 
-const ConfigApisProperties: NodeType = {
+const createConfigApisProperties = (nodeTypes: Record<string, NodeType>): NodeType => ({
+  ...nodeTypes['rootRedoclyConfigSchema.apis_additionalProperties'],
   properties: {
-    ...apiConfigSchema.properties,
-    root: { type: 'string' },
+    ...nodeTypes['rootRedoclyConfigSchema.apis_additionalProperties']?.properties,
     labels: {
       type: 'array',
       items: {
@@ -240,7 +295,6 @@ const ConfigApisProperties: NodeType = {
     ...ConfigStyleguide.properties,
     'features.openapi': 'ConfigReferenceDocs', // deprecated
     'features.mockServer': 'ConfigMockServer', // deprecated
-    theme: 'ConfigRootTheme',
     files: {
       type: 'array',
       items: {
@@ -248,8 +302,7 @@ const ConfigApisProperties: NodeType = {
       },
     },
   },
-  required: ['root'],
-};
+});
 
 const ConfigHTTP: NodeType = {
   properties: {
@@ -262,13 +315,13 @@ const ConfigHTTP: NodeType = {
   },
 };
 
-const ConfigRootTheme: NodeType = {
+const createConfigRootTheme = (nodeTypes: Record<string, NodeType>): NodeType => ({
+  ...nodeTypes['rootRedoclyConfigSchema.theme'],
   properties: {
-    ...themeConfigSchema.properties,
-    openapi: 'ConfigReferenceDocs',
-    mockServer: 'ConfigMockServer',
+    ...nodeTypes['rootRedoclyConfigSchema.theme']?.properties,
+    openapi: 'ConfigReferenceDocs', // Override theme.openapi with internal format
   },
-};
+});
 
 const Rules: NodeType = {
   properties: {},
@@ -302,7 +355,18 @@ const ObjectRule: NodeType = {
 
 const AssertionDefinitionSubject: NodeType = {
   properties: {
-    type: { enum: nodeTypesList },
+    type: {
+      enum: [
+        ...new Set([
+          'any',
+          ...oas2NodeTypesList,
+          ...oas3NodeTypesList,
+          ...oas3_1NodeTypesList,
+          ...asyncNodeTypesList,
+          'SpecExtension',
+        ]),
+      ],
+    },
     property: (value: unknown) => {
       if (Array.isArray(value)) {
         return { type: 'array', items: { type: 'string' } };
@@ -863,8 +927,9 @@ const GenerateCodeSamples: NodeType = {
 };
 
 const ConfigReferenceDocs: NodeType = {
+  // TODO: partially invalid @Viacheslav
   properties: {
-    theme: 'ConfigTheme',
+    theme: 'ConfigTheme', // TODO: deprecated @Viacheslav
     corsProxyUrl: { type: 'string' },
     ctrlFHijack: { type: 'boolean' },
     defaultSampleLanguage: { type: 'string' },
@@ -995,12 +1060,22 @@ const ConfigMockServer: NodeType = {
   },
 };
 
-export const ConfigTypes: Record<string, NodeType> = {
+export const createConfigTypes = (extraSchemas: JSONSchema) => {
+  // Create types based on external schemas
+  const nodeTypes = getNodeTypesFromJSONSchema('rootRedoclyConfigSchema', extraSchemas);
+
+  return {
+    ...CoreConfigTypes,
+    ConfigRoot: createConfigRoot(nodeTypes),
+    ConfigApisProperties: createConfigApisProperties(nodeTypes),
+    ConfigRootTheme: createConfigRootTheme(nodeTypes),
+    ...nodeTypes,
+  };
+};
+
+const CoreConfigTypes: Record<string, NodeType> = {
   Assert,
-  ConfigRoot,
   ConfigApis,
-  ConfigApisProperties,
-  RootConfigStyleguide,
   ConfigStyleguide,
   ConfigReferenceDocs,
   ConfigMockServer,
@@ -1010,7 +1085,6 @@ export const ConfigTypes: Record<string, NodeType> = {
   ConfigSidebarLinks,
   CommonConfigSidebarLinks,
   ConfigTheme,
-  ConfigRootTheme,
   AssertDefinition,
   ThemeColors,
   CommonThemeColors,
@@ -1060,5 +1134,6 @@ export const ConfigTypes: Record<string, NodeType> = {
   Typography,
   AssertionDefinitionAssertions,
   AssertionDefinitionSubject,
-  ...PortalConfigNodeTypes,
 };
+
+export const ConfigTypes: Record<string, NodeType> = createConfigTypes(rootRedoclyConfigSchema);
diff --git a/packages/core/src/types/theme-config.ts b/packages/core/src/types/theme-config.ts
index f679b755..ab35a954 100644
--- a/packages/core/src/types/theme-config.ts
+++ b/packages/core/src/types/theme-config.ts
@@ -1,3 +1,5 @@
+import type { FromSchema } from 'json-schema-to-ts';
+
 const logoConfigSchema = {
   type: 'object',
   properties: {
@@ -68,6 +70,11 @@ const markdownConfigSchema = {
       items: { type: 'string' },
       default: ['image', 'links'],
     },
+    partialsFolders: {
+      type: 'array',
+      items: { type: 'string' },
+      default: ['_partials'],
+    },
     lastUpdatedBlock: {
       type: 'object',
       properties: {
@@ -76,7 +83,7 @@ const markdownConfigSchema = {
           enum: ['timeago', 'iso', 'long', 'short'],
           default: 'timeago',
         },
-        locale: { type: 'string', default: 'en-US' },
+        locale: { type: 'string' },
         ...hideConfigSchema.properties,
       },
       additionalProperties: false,
@@ -187,6 +194,21 @@ const gtmAnalyticsConfigSchema = {
   required: ['trackingId'],
 } as const;
 
+const productGoogleAnalyticsConfigSchema = {
+  type: 'object',
+  properties: {
+    includeInDevelopment: { type: 'boolean' },
+    trackingId: { type: 'string' },
+
+    conversionId: { type: 'string' },
+    floodlightId: { type: 'string' },
+    optimizeId: { type: 'string' },
+    exclude: { type: 'array', items: { type: 'string' } },
+  },
+  additionalProperties: false,
+  required: ['trackingId'],
+} as const;
+
 const googleAnalyticsConfigSchema = {
   type: 'object',
   properties: {
@@ -203,6 +225,12 @@ const googleAnalyticsConfigSchema = {
     optimizeId: { type: 'string' },
     anonymizeIp: { type: 'boolean' },
     cookieExpires: { type: 'number' },
+
+    // All enabled tracking configs
+    trackers: {
+      type: 'object',
+      additionalProperties: productGoogleAnalyticsConfigSchema,
+    },
   },
   additionalProperties: false,
   required: ['trackingId'],
@@ -224,6 +252,7 @@ const navItemSchema = {
   properties: {
     page: { type: 'string' },
     directory: { type: 'string' },
+    disconnect: { type: 'boolean', default: false },
     group: { type: 'string' },
     label: { type: 'string' },
     separator: { type: 'string' },
@@ -264,7 +293,7 @@ const productConfigSchema = {
     folder: { type: 'string' },
   },
   additionalProperties: false,
-  required: ['name', 'icon', 'folder'],
+  required: ['name', 'folder'],
 } as const;
 
 const suggestedPageSchema = {
@@ -287,6 +316,7 @@ const catalogFilterSchema = {
     titleTranslationKey: { type: 'string' },
     property: { type: 'string' },
     parentFilter: { type: 'string' },
+    valuesMapping: { type: 'object', additionalProperties: { type: 'string' } },
     missingCategoryName: { type: 'string' },
     missingCategoryNameTranslationKey: { type: 'string' },
     options: { type: 'array', items: { type: 'string' } },
@@ -296,9 +326,9 @@ const catalogFilterSchema = {
 const scorecardConfigSchema = {
   type: 'object',
   additionalProperties: true,
-  required: ['levels'],
+  required: [],
   properties: {
-    failBuildIfBelowMinimum: { type: 'boolean', default: false },
+    ignoreNonCompliant: { type: 'boolean', default: false },
     teamMetadataProperty: {
       type: 'object',
       properties: {
@@ -314,11 +344,12 @@ const scorecardConfigSchema = {
         required: ['name'],
         properties: {
           name: { type: 'string' },
+          color: { type: 'string' },
           extends: { type: 'array', items: { type: 'string' } },
           rules: {
             type: 'object',
             additionalProperties: {
-              type: ['object', 'string'],
+              oneOf: [{ type: 'string' }, { type: 'object' }],
             },
           },
         },
@@ -420,12 +451,6 @@ export const themeConfigSchema = {
       },
       additionalProperties: false,
     },
-    seo: {
-      type: 'object',
-      properties: {
-        title: { type: 'string' },
-      },
-    },
     scripts: {
       type: 'object',
       properties: {
@@ -444,7 +469,7 @@ export const themeConfigSchema = {
         },
         type: {
           type: 'string',
-          enum: ['rating', 'sentiment', 'comment', 'reasons'],
+          enum: ['rating', 'sentiment', 'comment', 'reasons', 'mood', 'scale'],
           default: 'sentiment',
         },
         settings: {
@@ -452,15 +477,27 @@ export const themeConfigSchema = {
           properties: {
             label: { type: 'string' },
             submitText: { type: 'string' },
-            max: { type: 'number' },
             buttonText: { type: 'string' },
-            multi: { type: 'boolean' },
+            component: {
+              type: 'string',
+              enum: ['radio', 'checkbox'],
+              default: 'checkbox',
+            },
             items: { type: 'array', items: { type: 'string' }, minItems: 1 },
+            leftScaleLabel: { type: 'string' },
+            rightScaleLabel: { type: 'string' },
             reasons: {
               type: 'object',
               properties: {
-                enable: { type: 'boolean', default: true },
-                multi: { type: 'boolean' },
+                hide: {
+                  type: 'boolean',
+                  default: false,
+                },
+                component: {
+                  type: 'string',
+                  enum: ['radio', 'checkbox'],
+                  default: 'checkbox',
+                },
                 label: { type: 'string' },
                 items: { type: 'array', items: { type: 'string' } },
               },
@@ -469,10 +506,16 @@ export const themeConfigSchema = {
             comment: {
               type: 'object',
               properties: {
-                enable: { type: 'boolean', default: true },
+                hide: {
+                  type: 'boolean',
+                  default: false,
+                },
                 label: { type: 'string' },
                 likeLabel: { type: 'string' },
                 dislikeLabel: { type: 'string' },
+                satisfiedLabel: { type: 'string' },
+                neutralLabel: { type: 'string' },
+                dissatisfiedLabel: { type: 'string' },
               },
               additionalProperties: false,
             },
@@ -525,7 +568,7 @@ export const themeConfigSchema = {
         nextButton: {
           type: 'object',
           properties: {
-            text: { type: 'string', default: 'Next to {label}' },
+            text: { type: 'string', default: 'Next to {{label}}' },
             ...hideConfigSchema.properties,
           },
           additionalProperties: false,
@@ -534,7 +577,7 @@ export const themeConfigSchema = {
         previousButton: {
           type: 'object',
           properties: {
-            text: { type: 'string', default: 'Back to {label}' },
+            text: { type: 'string', default: 'Back to {{label}}' },
             ...hideConfigSchema.properties,
           },
           additionalProperties: false,
@@ -547,7 +590,7 @@ export const themeConfigSchema = {
     codeSnippet: {
       type: 'object',
       properties: {
-        controlsStyle: { type: 'string', default: 'icon' },
+        elementFormat: { type: 'string', default: 'icon' },
         copy: {
           type: 'object',
           properties: {
@@ -559,10 +602,13 @@ export const themeConfigSchema = {
         report: {
           type: 'object',
           properties: {
+            tooltipText: { type: 'string' },
+            buttonText: { type: 'string' },
+            label: { type: 'string' },
             ...hideConfigSchema.properties,
           },
           additionalProperties: false,
-          default: { hide: true },
+          default: { hide: false },
         },
         expand: {
           type: 'object',
@@ -585,7 +631,7 @@ export const themeConfigSchema = {
       default: {},
     },
     markdown: markdownConfigSchema,
-    openapi: { type: 'object', additionalProperties: true },
+    openapi: { type: 'object', additionalProperties: true }, // TODO: put the real schema here @Viacheslav
     graphql: { type: 'object', additionalProperties: true },
     analytics: {
       type: 'object',
@@ -671,13 +717,65 @@ export const productThemeOverrideSchema = {
     search: themeConfigSchema.properties.search,
     codeSnippet: themeConfigSchema.properties.codeSnippet,
     breadcrumbs: themeConfigSchema.properties.breadcrumbs,
+    analytics: {
+      type: 'object',
+      properties: {
+        ga: productGoogleAnalyticsConfigSchema,
+      },
+    },
   },
   additionalProperties: true,
   default: {},
 } as const;
 
-export enum ScorecardStatus {
-  BelowMinimum = 'Below minimum',
-  Highest = 'Highest',
-  Minimum = 'Minimum',
-}
+export type ThemeConfig = FromSchema<typeof themeConfigSchema>;
+
+// TODO: cannot export  as it relies on external types
+// export type ThemeUIConfig = ThemeConfig & {
+//   auth?: {
+//     // used by portal dev login emulator
+//     idpsInfo?: {
+//       idpId: string;
+//       type: string; // AuthProviderType
+//       title: string | undefined;
+//     }[];
+//     devLogin?: boolean;
+//     loginUrls?: Record<string, string>;
+//   };
+//   search?: {
+//     shortcuts?: string[];
+//     suggestedPages?: any[];
+//   };
+//   breadcrumbs?: {
+//     prefixItems?: ResolvedNavLinkItem[];
+//   };
+//   products?: {
+//     [key: string]: ProductUiConfig;
+//   };
+// };
+
+export type ProductConfig = FromSchema<typeof productConfigSchema>;
+
+export type ProductGoogleAnalyticsConfig = FromSchema<typeof productGoogleAnalyticsConfigSchema>;
+// TODO: cannot export  as it relies on external types
+// export type ProductThemeOverrideConfig = Pick<
+//   ThemeUIConfig,
+//   'logo' | 'navbar' | 'footer' | 'sidebar' | 'search' | 'codeSnippet' | 'breadcrumbs'
+// > & { analytics?: { ga?: ProductGoogleAnalyticsConfig } };
+// export type ProductUiConfig = ProductConfig & {
+//   slug: string;
+//   link: string;
+//   [REDOCLY_TEAMS_RBAC]?: { [key: string]: string };
+//   themeOverride?: ProductThemeOverrideConfig;
+// };
+
+export type MarkdownConfig = FromSchema<typeof markdownConfigSchema>;
+
+export type AmplitudeAnalyticsConfig = FromSchema<typeof amplitudeAnalyticsConfigSchema>;
+export type RudderstackAnalyticsConfig = FromSchema<typeof rudderstackAnalyticsConfigSchema>;
+export type SegmentAnalyticsConfig = FromSchema<typeof segmentAnalyticsConfigSchema>;
+export type GtmAnalyticsConfig = FromSchema<typeof gtmAnalyticsConfigSchema>;
+export type GoogleAnalyticsConfig = FromSchema<typeof googleAnalyticsConfigSchema>;
+export type CatalogConfig = FromSchema<typeof catalogSchema>;
+export type CatalogFilterConfig = FromSchema<typeof catalogFilterSchema>;
+export type ScorecardConfig = FromSchema<typeof scorecardConfigSchema>;
diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts
index 0cc3bb46..33034e14 100644
--- a/packages/core/src/utils.ts
+++ b/packages/core/src/utils.ts
@@ -37,11 +37,11 @@ export function isDefined<T>(x: T | undefined): x is T {
   return x !== undefined;
 }
 
-export function isPlainObject(value: any): value is object {
+export function isPlainObject(value: any): value is Record<string, unknown> {
   return value !== null && typeof value === 'object' && !Array.isArray(value);
 }
 
-export function isEmptyObject(value: any): value is object {
+export function isEmptyObject(value: any): value is Record<string, unknown> {
   return isPlainObject(value) && Object.keys(value).length === 0;
 }
 
diff --git a/packages/core/src/visitors.ts b/packages/core/src/visitors.ts
index 18c5b477..3493117f 100644
--- a/packages/core/src/visitors.ts
+++ b/packages/core/src/visitors.ts
@@ -359,7 +359,7 @@ export function normalizeVisitors<T extends BaseVisitor>(
         possibleChildren.add(from.additionalProperties);
       }
     }
-    if (from.items) {
+    if (from.items && typeof from.items !== 'function') {
       if (from.items === to) {
         addWeakFromStack(ruleConf, stack);
       } else if (from.items.name !== undefined) {
diff --git a/packages/core/src/walk.ts b/packages/core/src/walk.ts
index e0e4494c..f1f30219 100644
--- a/packages/core/src/walk.ts
+++ b/packages/core/src/walk.ts
@@ -271,8 +271,14 @@ export function walkDocument<T extends BaseVisitor>(opts: {
         if (Array.isArray(resolvedNode)) {
           const itemsType = type.items;
           if (itemsType !== undefined) {
+            const isTypeAFunction = typeof itemsType === 'function';
             for (let i = 0; i < resolvedNode.length; i++) {
-              walkNode(resolvedNode[i], itemsType, resolvedLocation.child([i]), resolvedNode, i);
+              const itemType = isTypeAFunction
+                ? itemsType(resolvedNode[i], resolvedLocation.child([i]).absolutePointer)
+                : itemsType;
+              if (isNamedType(itemType)) {
+                walkNode(resolvedNode[i], itemType, resolvedLocation.child([i]), resolvedNode, i);
+              }
             }
           }
         } else if (typeof resolvedNode === 'object' && resolvedNode !== null) {


Test patch:
diff --git a/__tests__/commands.test.ts b/__tests__/commands.test.ts
index 254e9601..6da454a6 100644
--- a/__tests__/commands.test.ts
+++ b/__tests__/commands.test.ts
@@ -66,6 +66,7 @@ describe('E2E', () => {
       { dirName: 'invalid-config-format-json', option: 'warn', format: 'json' },
       { dirName: 'config-with-refs', option: 'warn' },
       { dirName: 'config-with-refs-extended', option: 'error' },
+      { dirName: 'config-structure', option: 'error' },
     ];
 
     const validOpenapiFile = join(__dirname, 'lint-config/__fixtures__/valid-openapi.yaml');
diff --git a/__tests__/lint-config/config-structure/.redocly.yaml b/__tests__/lint-config/config-structure/.redocly.yaml
new file mode 100644
index 00000000..638bab74
--- /dev/null
+++ b/__tests__/lint-config/config-structure/.redocly.yaml
@@ -0,0 +1,264 @@
+licenseKey: 123 # Must be a string
+
+apis:
+  without-root:
+    foo: Not expected!
+    output: file.json
+  with-wrong-root:
+    root: 456 # Must be a string
+  with-theme:
+    root: ./openapi.yaml
+    theme:
+      openapi: wrong, must be an object
+      not-expected: Must fail
+
+seo:
+  keywords: 789 # Must be an array
+
+redirects:
+  some-redirect:
+    t1o: Wrong name, should be 'two'
+    type: wrong type, must be a number
+
+rbac:
+  'team-b.md':
+    TeamB: read
+  team-c.md:
+    TeamC: read
+  /blog/*:
+    anonymous: none
+    authenticated: read
+  /blogpost/:
+    TeamD: none
+  '**/*.md':
+    TeamA: none
+    authenticated: none
+    '*': read
+  'blog/april-2022.md':
+    TeamA: none
+    TeamC: read
+  test.md:
+    TeamC: none
+    TeamB: none
+    authenticated: none
+    '*': read
+  test/**:
+    TeamB: read
+    TeamC: read
+    authenticated: read
+    anonymous: read
+  additional-property:
+    something: 123 # Must be a string
+  content:
+    '**':
+      additionalProp: 456 # Must be a stirng
+    foo:
+      additionalProp2: 789 # Must be a stirng
+
+responseHeaders:
+  some-header: wrong, must be an array
+  some-header2:
+    - wrong, must be an object
+    - unexpected-property: Should fail
+      # name: Must be reported as a missing required prop
+      value: 123 # Must be a string
+
+ssoOnPrem:
+  oidc:
+    title: 456 # Must be a string
+    type: OIDC
+    configurationUrl: http://localhost/oidc/.well-known/openid-configuration
+    clientId: '{{ process.env.public }}'
+    clientSecret: '{{ process.env.secret }}'
+    teamsClaimName: https://test.com
+    scopes:
+      - openid
+    audience:
+      - default
+    authorizationRequestCustomParams:
+      login_hint: 789 # Must be a string
+      prompt: login
+    configuration:
+      token_endpoint: 123 # Must be a string
+      # authorization_endpoint: Must be reported as a missing required prop
+      additional-propery: Must be allowed
+    defaultTeams:
+      - 456 # Must be a string
+
+  sso-config-schema-without-configurationUrl:
+    type: OIDC
+    # clientId: Must be reported as a missing required prop
+    # configurationUrl: Must be reported as a missing required prop
+    clientSecret: '{{ process.env.secret }}'
+
+  basic:
+    type: BASIC
+    credentials:
+      - teams:
+          - 789 # Must be a string
+          - correct
+        # username: Must be reported as a missing required prop
+
+sso:
+  - WRONG # Does not match allowed options
+
+developerOnboarding:
+  wrong: A not allowed field
+  adapters:
+    - should be object
+    - type: 123 # Must be a string
+    - type: APIGEE_X
+      # organizationName: Must be reported as a missing required prop
+      auth:
+        type: OAUTH2
+        # tokenEndpoint: Must be reported as a missing required prop
+        clientId: 456 # Must be a string
+        clientSecret: '{{ process.env.secret }}'
+        not-expected: Must fail
+    - type: APIGEE_X
+      organizationName: Test
+      auth:
+        type: SERVICE_ACCOUNT
+        # serviceAccountPrivateKey: Must be reported as a missing required prop
+        serviceAccountEmail: 789 # Must be a string
+
+i18n:
+  defaultLocale: en-US
+  locales:
+    - code: 123 # Must be a string
+      name: English
+    - code: es-ES
+      name: Spanish
+
+metadata:
+  test: anything
+
+not-listed-filed: Must be reported as not expected
+
+env:
+  some-env:
+    mockServer:
+      off: must be boolean
+      not-expected: Must fail
+    apis:
+      no-root:
+        # root: Must be defined
+        rules: {}
+      wrong-root:
+        root: 789 # Must be a string
+
+  referenced-env:
+    $ref: ./env.yaml
+
+theme:
+  breadcrumbs:
+    hide: false
+    prefixItems:
+      - label: Home
+        page: '/'
+  imports:
+    - '@redocly/test-theme'
+
+  logo:
+    srcSet: './images/redocly-black-logo.svg light, ./images/redocly-brand-logo.svg dark'
+    altText: Test
+    link: /
+  asyncapi:
+    hideInfo: false
+    expandSchemas:
+      root: true
+      elements: true
+  navbar:
+    items:
+      - label: Markdown
+        page: /markdown/
+
+  search:
+    shortcuts:
+      - ctrl+f
+      - cmd+k
+      - /
+    suggestedPages:
+      - label: TSX page
+        page: tsx.page.tsx
+      - page: /my-catalog/
+
+  footer:
+    copyrightText: Copyright © Test 2019-2020.
+    items:
+      - group: Legal
+        items:
+          - label: Terms of Use
+            href: 'https://test.com/' # Not expected
+
+  markdown:
+    lastUpdatedBlock:
+      format: 'long'
+    editPage:
+      baseUrl: https://test.com
+  graphql:
+    pagination: section
+    menu:
+      {
+        initialLoadState: 'default',
+        requireExactGroups: false,
+        groups:
+          [
+            {
+              name: 'GraphQL custom group',
+              directives: { includeByName: ['cacheControl', 'typeDirective'] },
+            },
+          ],
+        otherItemsGroupName: 'Other',
+      }
+  sidebar:
+    separatorLine: true
+    linePosition: top
+  catalog:
+    main:
+      title: API Catalog
+      description: 'This is a description of the API Catalog'
+      slug: /my-catalog/
+      filters:
+        - title: Domain
+          property: domain
+          missingCategoryName: Other
+        - title: API Category
+          property: category
+          missingCategoryName: Other
+      groupByFirstFilter: false
+      items:
+        - directory: ./
+          flatten: true
+          includeByMetadata:
+            type: [openapi]
+  scorecard:
+    ignoreNonCompliant: true
+    levels:
+      - name: Baseline
+        extends:
+          - minimal
+      - name: Silver
+        extends:
+          - recommended
+        rules:
+          info-description: off
+
+      - name: Gold
+        rules:
+          rule/path-item-get-required:
+            severity: warn
+            subject:
+              type: PathItem
+            message: Every path item must have a GET operation.
+            assertions:
+              required:
+                - get
+
+          operation-4xx-response: warn
+    targets:
+      - where:
+          metadata:
+            l0: Distribution
+            publishDateRange: 2021-01-01T00:00:00Z/2022-01-01
+        minimumLevel: Silver
diff --git a/__tests__/lint-config/config-structure/env.yaml b/__tests__/lint-config/config-structure/env.yaml
new file mode 100644
index 00000000..4b43b5cc
--- /dev/null
+++ b/__tests__/lint-config/config-structure/env.yaml
@@ -0,0 +1,3 @@
+ignore:
+  - 123 # Must be an string
+  - correct
diff --git a/__tests__/lint-config/config-structure/snapshot.js b/__tests__/lint-config/config-structure/snapshot.js
new file mode 100644
index 00000000..e595bd88
--- /dev/null
+++ b/__tests__/lint-config/config-structure/snapshot.js
@@ -0,0 +1,671 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`E2E lint-config test with option: { dirName: 'config-structure', option: 'error' } 1`] = `
+
+[1] .redocly.yaml:1:13 at #/licenseKey
+
+Expected type \`string\` but got \`integer\`.
+
+1 | licenseKey: 123 # Must be a string
+  |             ^^^
+2 |
+3 | apis:
+
+Error was generated by the configuration spec rule.
+
+
+[2] .redocly.yaml:103:5 at #/sso/0
+
+\`sso\` can be one of the following only: "REDOCLY", "CORPORATE", "GUEST".
+
+101 |
+102 | sso:
+103 |   - WRONG # Does not match allowed options
+    |     ^^^^^
+104 |
+105 | developerOnboarding:
+
+Error was generated by the configuration spec rule.
+
+
+[3] .redocly.yaml:136:1 at #/not-listed-filed
+
+Property \`not-listed-filed\` is not expected here.
+
+134 |   test: anything
+135 |
+136 | not-listed-filed: Must be reported as not expected
+    | ^^^^^^^^^^^^^^^^
+137 |
+138 | env:
+
+Error was generated by the configuration spec rule.
+
+
+[4] .redocly.yaml:20:5 at #/redirects/some-redirect/t1o
+
+Property \`t1o\` is not expected here.
+
+Did you mean:
+  - to
+  - type
+
+18 | redirects:
+19 |   some-redirect:
+20 |     t1o: Wrong name, should be 'two'
+   |     ^^^
+21 |     type: wrong type, must be a number
+22 |
+
+Error was generated by the configuration spec rule.
+
+
+[5] .redocly.yaml:21:11 at #/redirects/some-redirect/type
+
+Expected type \`number\` but got \`string\`.
+
+19 |   some-redirect:
+20 |     t1o: Wrong name, should be 'two'
+21 |     type: wrong type, must be a number
+   |           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+22 |
+23 | rbac:
+
+Error was generated by the configuration spec rule.
+
+
+[6] .redocly.yaml:16:13 at #/seo/keywords
+
+Expected type \`array\` but got \`integer\`.
+
+14 |
+15 | seo:
+16 |   keywords: 789 # Must be an array
+   |             ^^^
+17 |
+18 | redirects:
+
+Error was generated by the configuration spec rule.
+
+
+[7] .redocly.yaml:54:23 at #/rbac/content/**/additionalProp
+
+Expected type \`string\` but got \`integer\`.
+
+52 | content:
+53 |   '**':
+54 |     additionalProp: 456 # Must be a stirng
+   |                     ^^^
+55 |   foo:
+56 |     additionalProp2: 789 # Must be a stirng
+
+Error was generated by the configuration spec rule.
+
+
+[8] .redocly.yaml:56:24 at #/rbac/content/foo/additionalProp2
+
+Expected type \`string\` but got \`integer\`.
+
+54 |       additionalProp: 456 # Must be a stirng
+55 |     foo:
+56 |       additionalProp2: 789 # Must be a stirng
+   |                        ^^^
+57 |
+58 | responseHeaders:
+
+Error was generated by the configuration spec rule.
+
+
+[9] .redocly.yaml:51:16 at #/rbac/additional-property/something
+
+Expected type \`string\` but got \`integer\`.
+
+49 |   anonymous: read
+50 | additional-property:
+51 |   something: 123 # Must be a string
+   |              ^^^
+52 | content:
+53 |   '**':
+
+Error was generated by the configuration spec rule.
+
+
+[10] .redocly.yaml:59:16 at #/responseHeaders/some-header
+
+Expected type \`rootRedoclyConfigSchema.responseHeaders_additionalProperties\` (array) but got \`string\`
+
+57 |
+58 | responseHeaders:
+59 |   some-header: wrong, must be an array
+   |                ^^^^^^^^^^^^^^^^^^^^^^^
+60 |   some-header2:
+61 |     - wrong, must be an object
+
+Error was generated by the configuration spec rule.
+
+
+[11] .redocly.yaml:61:7 at #/responseHeaders/some-header2/0
+
+Expected type \`rootRedoclyConfigSchema.responseHeaders_additionalProperties_items\` (object) but got \`string\`
+
+59 | some-header: wrong, must be an array
+60 | some-header2:
+61 |   - wrong, must be an object
+   |     ^^^^^^^^^^^^^^^^^^^^^^^^
+62 |   - unexpected-property: Should fail
+63 |     # name: Must be reported as a missing required prop
+
+Error was generated by the configuration spec rule.
+
+
+[12] .redocly.yaml:62:7 at #/responseHeaders/some-header2/1
+
+The field \`name\` must be present on this level.
+
+60 |   some-header2:
+61 |     - wrong, must be an object
+62 |     - unexpected-property: Should fail
+   |       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+63 |       # name: Must be reported as a missing required prop
+   |       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+64 |       value: 123 # Must be a string
+   |       ^^^^^^^^^^
+65 |
+66 | ssoOnPrem:
+
+Error was generated by the configuration spec rule.
+
+
+[13] .redocly.yaml:62:7 at #/responseHeaders/some-header2/1/unexpected-property
+
+Property \`unexpected-property\` is not expected here.
+
+60 | some-header2:
+61 |   - wrong, must be an object
+62 |   - unexpected-property: Should fail
+   |     ^^^^^^^^^^^^^^^^^^^
+63 |     # name: Must be reported as a missing required prop
+64 |     value: 123 # Must be a string
+
+Error was generated by the configuration spec rule.
+
+
+[14] .redocly.yaml:64:14 at #/responseHeaders/some-header2/1/value
+
+Expected type \`string\` but got \`integer\`.
+
+62 |     - unexpected-property: Should fail
+63 |       # name: Must be reported as a missing required prop
+64 |       value: 123 # Must be a string
+   |              ^^^
+65 |
+66 | ssoOnPrem:
+
+Error was generated by the configuration spec rule.
+
+
+[15] .redocly.yaml:4:3 at #/apis/without-root
+
+The field \`root\` must be present on this level.
+
+2 |
+3 | apis:
+4 |   without-root:
+  |   ^^^^^^^^^^^^
+5 |     foo: Not expected!
+6 |     output: file.json
+
+Error was generated by the configuration spec rule.
+
+
+[16] .redocly.yaml:5:5 at #/apis/without-root/foo
+
+Property \`foo\` is not expected here.
+
+Did you mean: root ?
+
+3 | apis:
+4 |   without-root:
+5 |     foo: Not expected!
+  |     ^^^
+6 |     output: file.json
+7 |   with-wrong-root:
+
+Error was generated by the configuration spec rule.
+
+
+[17] .redocly.yaml:8:11 at #/apis/with-wrong-root/root
+
+Expected type \`string\` but got \`integer\`.
+
+ 6 |   output: file.json
+ 7 | with-wrong-root:
+ 8 |   root: 456 # Must be a string
+   |         ^^^
+ 9 | with-theme:
+10 |   root: ./openapi.yaml
+
+Error was generated by the configuration spec rule.
+
+
+[18] .redocly.yaml:12:16 at #/apis/with-theme/theme/openapi
+
+Expected type \`object\` but got \`string\`.
+
+10 | root: ./openapi.yaml
+11 | theme:
+12 |   openapi: wrong, must be an object
+   |            ^^^^^^^^^^^^^^^^^^^^^^^^
+13 |   not-expected: Must fail
+14 |
+
+Error was generated by the configuration spec rule.
+
+
+[19] .redocly.yaml:13:7 at #/apis/with-theme/theme/not-expected
+
+Property \`not-expected\` is not expected here.
+
+11 |     theme:
+12 |       openapi: wrong, must be an object
+13 |       not-expected: Must fail
+   |       ^^^^^^^^^^^^
+14 |
+15 | seo:
+
+Error was generated by the configuration spec rule.
+
+
+[20] .redocly.yaml:68:12 at #/ssoOnPrem/oidc/title
+
+Expected type \`string\` but got \`integer\`.
+
+66 | ssoOnPrem:
+67 |   oidc:
+68 |     title: 456 # Must be a string
+   |            ^^^
+69 |     type: OIDC
+70 |     configurationUrl: http://localhost/oidc/.well-known/openid-configuration
+
+Error was generated by the configuration spec rule.
+
+
+[21] .redocly.yaml:86:9 at #/ssoOnPrem/oidc/defaultTeams/0
+
+Expected type \`string\` but got \`integer\`.
+
+84 |     additional-propery: Must be allowed
+85 |   defaultTeams:
+86 |     - 456 # Must be a string
+   |       ^^^
+87 |
+88 | sso-config-schema-without-configurationUrl:
+
+Error was generated by the configuration spec rule.
+
+
+[22] .redocly.yaml:81:5 at #/ssoOnPrem/oidc/configuration
+
+The field \`authorization_endpoint\` must be present on this level.
+
+79 |   login_hint: 789 # Must be a string
+80 |   prompt: login
+81 | configuration:
+   | ^^^^^^^^^^^^^
+82 |   token_endpoint: 123 # Must be a string
+83 |   # authorization_endpoint: Must be reported as a missing required prop
+
+Error was generated by the configuration spec rule.
+
+
+[23] .redocly.yaml:82:23 at #/ssoOnPrem/oidc/configuration/token_endpoint
+
+Expected type \`string\` but got \`integer\`.
+
+80 |   prompt: login
+81 | configuration:
+82 |   token_endpoint: 123 # Must be a string
+   |                   ^^^
+83 |   # authorization_endpoint: Must be reported as a missing required prop
+84 |   additional-propery: Must be allowed
+
+Error was generated by the configuration spec rule.
+
+
+[24] .redocly.yaml:79:19 at #/ssoOnPrem/oidc/authorizationRequestCustomParams/login_hint
+
+Expected type \`string\` but got \`integer\`.
+
+77 |   - default
+78 | authorizationRequestCustomParams:
+79 |   login_hint: 789 # Must be a string
+   |               ^^^
+80 |   prompt: login
+81 | configuration:
+
+Error was generated by the configuration spec rule.
+
+
+[25] .redocly.yaml:88:3 at #/ssoOnPrem/sso-config-schema-without-configurationUrl
+
+The field \`clientId\` must be present on this level.
+
+86 |     - 456 # Must be a string
+87 |
+88 | sso-config-schema-without-configurationUrl:
+   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+89 |   type: OIDC
+90 |   # clientId: Must be reported as a missing required prop
+
+Error was generated by the configuration spec rule.
+
+
+[26] .redocly.yaml:88:3 at #/ssoOnPrem/sso-config-schema-without-configurationUrl
+
+The field \`configurationUrl\` must be present on this level.
+
+86 |     - 456 # Must be a string
+87 |
+88 | sso-config-schema-without-configurationUrl:
+   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+89 |   type: OIDC
+90 |   # clientId: Must be reported as a missing required prop
+
+Error was generated by the configuration spec rule.
+
+
+[27] .redocly.yaml:97:9 at #/ssoOnPrem/basic/credentials/0
+
+The field \`username\` must be present on this level.
+
+ 95 |     type: BASIC
+ 96 |     credentials:
+ 97 |       - teams:
+    |         ^^^^^^
+ 98 |           - 789 # Must be a string
+    |           ^^^^^^^^^^^^^^^^^^^^^^^^
+  … |           < 2 more lines >
+101 |
+    | ^
+102 | sso:
+103 |   - WRONG # Does not match allowed options
+
+Error was generated by the configuration spec rule.
+
+
+[28] .redocly.yaml:98:13 at #/ssoOnPrem/basic/credentials/0/teams/0
+
+Expected type \`string\` but got \`integer\`.
+
+ 96 | credentials:
+ 97 |   - teams:
+ 98 |       - 789 # Must be a string
+    |         ^^^
+ 99 |       - correct
+100 |     # username: Must be reported as a missing required prop
+
+Error was generated by the configuration spec rule.
+
+
+[29] .redocly.yaml:106:3 at #/developerOnboarding/wrong
+
+Property \`wrong\` is not expected here.
+
+104 |
+105 | developerOnboarding:
+106 |   wrong: A not allowed field
+    |   ^^^^^
+107 |   adapters:
+108 |     - should be object
+
+Error was generated by the configuration spec rule.
+
+
+[30] .redocly.yaml:108:7 at #/developerOnboarding/adapters/0
+
+Expected type \`APIGEE_X\` (object) but got \`string\`
+
+106 | wrong: A not allowed field
+107 | adapters:
+108 |   - should be object
+    |     ^^^^^^^^^^^^^^^^
+109 |   - type: 123 # Must be a string
+110 |   - type: APIGEE_X
+
+Error was generated by the configuration spec rule.
+
+
+[31] .redocly.yaml:109:7 at #/developerOnboarding/adapters/1
+
+The field \`organizationName\` must be present on this level.
+
+107 | adapters:
+108 |   - should be object
+109 |   - type: 123 # Must be a string
+    |     ^^^^^^^^^
+110 |   - type: APIGEE_X
+111 |     # organizationName: Must be reported as a missing required prop
+
+Error was generated by the configuration spec rule.
+
+
+[32] .redocly.yaml:109:7 at #/developerOnboarding/adapters/1
+
+The field \`auth\` must be present on this level.
+
+107 | adapters:
+108 |   - should be object
+109 |   - type: 123 # Must be a string
+    |     ^^^^^^^^^
+110 |   - type: APIGEE_X
+111 |     # organizationName: Must be reported as a missing required prop
+
+Error was generated by the configuration spec rule.
+
+
+[33] .redocly.yaml:109:13 at #/developerOnboarding/adapters/1/type
+
+Expected type \`string\` but got \`integer\`.
+
+107 | adapters:
+108 |   - should be object
+109 |   - type: 123 # Must be a string
+    |           ^^^
+110 |   - type: APIGEE_X
+111 |     # organizationName: Must be reported as a missing required prop
+
+Error was generated by the configuration spec rule.
+
+
+[34] .redocly.yaml:110:7 at #/developerOnboarding/adapters/2
+
+The field \`organizationName\` must be present on this level.
+
+108 | - should be object
+109 | - type: 123 # Must be a string
+110 | - type: APIGEE_X
+    |   ^^^^^^^^^^^^^^
+111 |   # organizationName: Must be reported as a missing required prop
+    |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+  … |   < 5 more lines >
+117 |     not-expected: Must fail
+    |     ^^^^^^^^^^^^^^^^^^^^^^^
+118 | - type: APIGEE_X
+119 |   organizationName: Test
+
+Error was generated by the configuration spec rule.
+
+
+[35] .redocly.yaml:112:7 at #/developerOnboarding/adapters/2/auth
+
+The field \`tokenEndpoint\` must be present on this level.
+
+110 | - type: APIGEE_X
+111 |   # organizationName: Must be reported as a missing required prop
+112 |   auth:
+    |   ^^^^
+113 |     type: OAUTH2
+114 |     # tokenEndpoint: Must be reported as a missing required prop
+
+Error was generated by the configuration spec rule.
+
+
+[36] .redocly.yaml:115:19 at #/developerOnboarding/adapters/2/auth/clientId
+
+Expected type \`string\` but got \`integer\`.
+
+113 | type: OAUTH2
+114 | # tokenEndpoint: Must be reported as a missing required prop
+115 | clientId: 456 # Must be a string
+    |           ^^^
+116 | clientSecret: '{{ process.env.secret }}'
+117 | not-expected: Must fail
+
+Error was generated by the configuration spec rule.
+
+
+[37] .redocly.yaml:117:9 at #/developerOnboarding/adapters/2/auth/not-expected
+
+Property \`not-expected\` is not expected here.
+
+115 |     clientId: 456 # Must be a string
+116 |     clientSecret: '{{ process.env.secret }}'
+117 |     not-expected: Must fail
+    |     ^^^^^^^^^^^^
+118 | - type: APIGEE_X
+119 |   organizationName: Test
+
+Error was generated by the configuration spec rule.
+
+
+[38] .redocly.yaml:120:7 at #/developerOnboarding/adapters/3/auth
+
+The field \`serviceAccountPrivateKey\` must be present on this level.
+
+118 | - type: APIGEE_X
+119 |   organizationName: Test
+120 |   auth:
+    |   ^^^^
+121 |     type: SERVICE_ACCOUNT
+122 |     # serviceAccountPrivateKey: Must be reported as a missing required prop
+
+Error was generated by the configuration spec rule.
+
+
+[39] .redocly.yaml:123:30 at #/developerOnboarding/adapters/3/auth/serviceAccountEmail
+
+Expected type \`string\` but got \`integer\`.
+
+121 |         type: SERVICE_ACCOUNT
+122 |         # serviceAccountPrivateKey: Must be reported as a missing required prop
+123 |         serviceAccountEmail: 789 # Must be a string
+    |                              ^^^
+124 |
+125 | i18n:
+
+Error was generated by the configuration spec rule.
+
+
+[40] .redocly.yaml:128:13 at #/i18n/locales/0/code
+
+Expected type \`string\` but got \`integer\`.
+
+126 | defaultLocale: en-US
+127 | locales:
+128 |   - code: 123 # Must be a string
+    |           ^^^
+129 |     name: English
+130 |   - code: es-ES
+
+Error was generated by the configuration spec rule.
+
+
+[41] .redocly.yaml:192:13 at #/theme/footer/items/0/items/0/href
+
+Property \`href\` is not expected here.
+
+190 |       items:
+191 |         - label: Terms of Use
+192 |           href: 'https://test.com/' # Not expected
+    |           ^^^^
+193 |
+194 | markdown:
+
+Error was generated by the configuration spec rule.
+
+
+[42] .redocly.yaml:141:12 at #/env/some-env/mockServer/off
+
+Expected type \`boolean\` but got \`string\`.
+
+139 | some-env:
+140 |   mockServer:
+141 |     off: must be boolean
+    |          ^^^^^^^^^^^^^^^
+142 |     not-expected: Must fail
+143 |   apis:
+
+Error was generated by the configuration spec rule.
+
+
+[43] .redocly.yaml:142:7 at #/env/some-env/mockServer/not-expected
+
+Property \`not-expected\` is not expected here.
+
+140 | mockServer:
+141 |   off: must be boolean
+142 |   not-expected: Must fail
+    |   ^^^^^^^^^^^^
+143 | apis:
+144 |   no-root:
+
+Error was generated by the configuration spec rule.
+
+
+[44] .redocly.yaml:144:7 at #/env/some-env/apis/no-root
+
+The field \`root\` must be present on this level.
+
+142 |   not-expected: Must fail
+143 | apis:
+144 |   no-root:
+    |   ^^^^^^^
+145 |     # root: Must be defined
+146 |     rules: {}
+
+Error was generated by the configuration spec rule.
+
+
+[45] .redocly.yaml:148:15 at #/env/some-env/apis/wrong-root/root
+
+Expected type \`string\` but got \`integer\`.
+
+146 |       rules: {}
+147 |     wrong-root:
+148 |       root: 789 # Must be a string
+    |             ^^^
+149 |
+150 | referenced-env:
+
+Error was generated by the configuration spec rule.
+
+
+[46] env.yaml:2:5 at #/ignore/0
+
+Expected type \`string\` but got \`integer\`.
+
+1 | ignore:
+2 |   - 123 # Must be an string
+  |     ^^^
+3 |   - correct
+4 |
+
+referenced from .redocly.yaml:151:5 at #/env/referenced-env 
+
+Error was generated by the configuration spec rule.
+
+
+❌ Your config has 46 errors.
+
+
+`;
diff --git a/__tests__/lint-config/config-with-refs-extended/snapshot.js b/__tests__/lint-config/config-with-refs-extended/snapshot.js
index 17bf398c..c8396753 100644
--- a/__tests__/lint-config/config-with-refs-extended/snapshot.js
+++ b/__tests__/lint-config/config-with-refs-extended/snapshot.js
@@ -54,7 +54,20 @@ 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
+[5] 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.
+
+
+[6] rules.yaml:2:1 at #/wrong-rule
 
 Property \`wrong-rule\` is not expected here.
 
@@ -68,19 +81,6 @@ 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/invalid-config-assertation-config-type/snapshot.js b/__tests__/lint-config/invalid-config-assertation-config-type/snapshot.js
index 0cd54919..1bf7a309 100644
--- a/__tests__/lint-config/invalid-config-assertation-config-type/snapshot.js
+++ b/__tests__/lint-config/invalid-config-assertation-config-type/snapshot.js
@@ -4,7 +4,7 @@ exports[`E2E lint-config test with option: { dirName: 'invalid-config-assertatio
 
 [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".
+\`type\` can be one of the following only: "any", "Root", "Tag", "TagList", "ExternalDocs", "SecurityRequirement", "SecurityRequirementList", "Info", "Contact", "License", "Paths", "PathItem", "Parameter", "ParameterList", "ParameterItems", "Operation", "Example", "ExamplesMap", "Examples", "Header", "Responses", "Response", "Schema", "Xml", "SchemaProperties", "NamedSchemas", "NamedResponses", "NamedParameters", "NamedSecuritySchemes", "SecurityScheme", "TagGroup", "TagGroups", "EnumDescriptions", "Logo", "XCodeSample", "XCodeSampleList", "XServer", "XServerList", "Server", "ServerList", "ServerVariable", "ServerVariablesMap", "Callback", "CallbacksMap", "RequestBody", "MediaTypesMap", "MediaType", "Encoding", "EncodingMap", "HeadersMap", "Link", "LinksMap", "DiscriminatorMapping", "Discriminator", "Components", "NamedExamples", "NamedRequestBodies", "NamedHeaders", "NamedLinks", "NamedCallbacks", "ImplicitFlow", "PasswordFlow", "ClientCredentials", "AuthorizationCode", "OAuth2Flows", "XUsePkce", "WebhooksMap", "NamedPathItems", "Message", "SpecExtension".
 
  7 | where:
  8 |   - subject:
diff --git a/packages/core/src/__tests__/lint.test.ts b/packages/core/src/__tests__/lint.test.ts
index dc097f4a..4ce2e1f2 100644
--- a/packages/core/src/__tests__/lint.test.ts
+++ b/packages/core/src/__tests__/lint.test.ts
@@ -6,6 +6,276 @@ import { BaseResolver } from '../resolve';
 import { loadConfig } from '../config/load';
 import { parseYamlToDocument, replaceSourceWithRef, makeConfig } from '../../__tests__/utils';
 import { detectSpec } from '../oas-types';
+import { themeConfigSchema } from '../types/theme-config';
+import { createConfigTypes } from '../types/redocly-yaml';
+
+const testPortalConfig = parseYamlToDocument(
+  outdent`
+    licenseKey: 123 # Must be a string
+
+    apis:
+      without-root:
+        foo: Not expected!
+        output: file.json
+      with-wrong-root:
+        root: 456 # Must be a string
+      with-theme:
+        root: ./openapi.yaml
+        theme:
+          openapi: wrong, must be an object
+          not-expected: Must fail
+
+    seo:
+      keywords: 789 # Must be an array
+
+    redirects:
+      some-redirect:
+        t1o: Wrong name, should be 'two'
+        type: wrong type, must be a number
+
+    rbac:
+      'team-b.md':
+        TeamB: read
+      team-c.md:
+        TeamC: read
+      /blog/*:
+        anonymous: none
+        authenticated: read
+      /blogpost/:
+        TeamD: none
+      '**/*.md':
+        TeamA: none
+        authenticated: none
+        '*': read
+      'blog/april-2022.md':
+        TeamA: none
+        TeamC: read
+      test.md:
+        TeamC: none
+        TeamB: none
+        authenticated: none
+        '*': read
+      test/**:
+        TeamB: read
+        TeamC: read
+        authenticated: read
+        anonymous: read
+      additional-property:
+        something: 123 # Must be a string
+      content:
+        '**':
+          additionalProp: 456 # Must be a stirng
+        foo:
+          additionalProp2: 789 # Must be a stirng
+
+    responseHeaders:
+      some-header: wrong, must be an array
+      some-header2:
+        - wrong, must be an object
+        - unexpected-property: Should fail
+          # name: Must be reported as a missing required prop
+          value: 123 # Must be a string
+
+    ssoOnPrem:
+      oidc:
+        title: 456 # Must be a string
+        type: OIDC
+        configurationUrl: http://localhost/oidc/.well-known/openid-configuration
+        clientId: '{{ process.env.public }}'
+        clientSecret: '{{ process.env.secret }}'
+        teamsClaimName: https://test.com
+        scopes:
+          - openid
+        audience:
+          - default
+        authorizationRequestCustomParams:
+          login_hint: 789 # Must be a string
+          prompt: login
+        configuration:
+          token_endpoint: 123 # Must be a string
+          # authorization_endpoint: Must be reported as a missing required prop
+          additional-propery: Must be allowed
+        defaultTeams:
+          - 456 # Must be a string
+
+      sso-config-schema-without-configurationUrl:
+        type: OIDC
+        # clientId: Must be reported as a missing required prop
+        # configurationUrl: Must be reported as a missing required prop
+        clientSecret: '{{ process.env.secret }}'
+
+      basic:
+        type: BASIC
+        credentials:
+          - teams:
+              - 789 # Must be a string
+              - correct
+            # username: Must be reported as a missing required prop
+
+    sso:
+      - WRONG # Does not match allowed options
+
+    developerOnboarding:
+      wrong: A not allowed field
+      adapters:
+        - should be object
+        - type: 123 # Must be a string
+        - type: APIGEE_X
+          # organizationName: Must be reported as a missing required prop
+          auth:
+            type: OAUTH2
+            # tokenEndpoint: Must be reported as a missing required prop
+            clientId: 456 # Must be a string
+            clientSecret: '{{ process.env.secret }}'
+            not-expected: Must fail
+        - type: APIGEE_X
+          organizationName: Test
+          auth:
+            type: SERVICE_ACCOUNT
+            # serviceAccountPrivateKey: Must be reported as a missing required prop
+            serviceAccountEmail: 789 # Must be a string
+
+    i18n:
+      defaultLocale: en-US
+      locales:
+        - code: 123 # Must be a string
+          name: English
+        - code: es-ES
+          name: Spanish
+
+    metadata:
+      test: anything
+
+    not-listed-filed: Must be reported as not expected
+
+    env:
+      some-env:
+        mockServer:
+          off: must be boolean
+          not-expected: Must fail
+        apis:
+          no-root:
+            # root: Must be defined
+            rules: {}
+          wrong-root:
+            root: 789 # Must be a string
+
+    theme:
+      breadcrumbs:
+        hide: false
+        prefixItems:
+          - label: Home
+            page: '/'
+      imports:
+        - '@redocly/theme-experimental'
+
+      logo:
+        srcSet: './images/redocly-black-logo.svg light, ./images/redocly-brand-logo.svg dark'
+        altText: Test
+        link: /
+      asyncapi:
+        hideInfo: false
+        expandSchemas:
+          root: true
+          elements: true
+      navbar:
+        items:
+          - label: Markdown
+            page: /markdown/
+
+      search:
+        shortcuts:
+          - ctrl+f
+          - cmd+k
+          - /
+        suggestedPages:
+          - label: TSX page
+            page: tsx.page.tsx
+          - page: /my-catalog/
+
+      footer:
+        copyrightText: Copyright © Test 2019-2020.
+        items:
+          - group: Legal
+            items:
+              - label: Terms of Use
+                href: 'https://test.com/' # Not expected
+
+      markdown:
+        lastUpdatedBlock:
+          format: 'long'
+        editPage:
+          baseUrl: https://test.com
+      graphql:
+        pagination: section
+        menu:
+          {
+            initialLoadState: 'default',
+            requireExactGroups: false,
+            groups:
+              [
+                {
+                  name: 'GraphQL custom group',
+                  directives: { includeByName: ['cacheControl', 'typeDirective'] },
+                },
+              ],
+            otherItemsGroupName: 'Other',
+          }
+      sidebar:
+        separatorLine: true
+        linePosition: top
+      catalog:
+        main:
+          title: API Catalog
+          description: 'This is a description of the API Catalog'
+          slug: /my-catalog/
+          filters:
+            - title: Domain
+              property: domain
+              missingCategoryName: Other
+            - title: API Category
+              property: category
+              missingCategoryName: Other
+          groupByFirstFilter: false
+          items:
+            - directory: ./
+              flatten: true
+              includeByMetadata:
+                type: [openapi]
+      scorecard:
+        ignoreNonCompliant: true
+        levels:
+          - name: Baseline
+            extends:
+              - minimal
+          - name: Silver
+            extends:
+              - recommended
+            rules:
+              info-description: off
+
+          - name: Gold
+            rules:
+              rule/path-item-get-required:
+                severity: warn
+                subject:
+                  type: PathItem
+                message: Every path item must have a GET operation.
+                assertions:
+                  required:
+                    - get
+
+              operation-4xx-response: warn
+        targets:
+          - where:
+              metadata:
+                l0: Distribution
+                publishDateRange: 2021-01-01T00:00:00Z/2022-01-01
+            minimumLevel: Silver
+
+  `,
+  ''
+);
 
 describe('lint', () => {
   it('lintFromString should work', async () => {
@@ -97,28 +367,28 @@ describe('lint', () => {
             assertions:
               local/checkWordsCount:
                 min: 3
-      theme:
-        openapi:
-          showConsole: true
-          layout:
-            scope: section
-          routingStrategy: browser
-          theme:
-            rightPanel:
-              backgroundColor: '#263238'
-            links:
-              color: '#6CC496'
+        theme:
+          openapi:
+            showConsole: true
+            layout:
+              scope: section
+            routingStrategy: browser
             theme:
-              openapi:
-              showConsole: true
-              layout:
-                scope: section
-              routingStrategy: browser
+              rightPanel:
+                backgroundColor: '#263238'
+              links:
+                color: '#6CC496'
               theme:
-                rightPanel:
-                  backgroundColor: '#263238'
-                links:
-                  color: '#6CC496'
+                openapi:
+                  showConsole: true
+                  layout:
+                    scope: section
+                  routingStrategy: browser
+                  theme:
+                    rightPanel:
+                      backgroundColor: '#263238'
+                    links:
+                      color: '#6CC496'
       `,
       ''
     );
@@ -130,31 +400,26 @@ describe('lint', () => {
           "from": undefined,
           "location": [
             {
-              "pointer": "#/eme",
-              "reportOnKey": true,
+              "pointer": "#/apis",
+              "reportOnKey": false,
               "source": "",
             },
           ],
-          "message": "Property \`eme\` is not expected here.",
+          "message": "Expected type \`ConfigApis\` (object) but got \`string\`",
           "ruleId": "configuration spec",
           "severity": "error",
-          "suggest": [
-            "env",
-            "theme",
-            "seo",
-            "sso",
-          ],
+          "suggest": [],
         },
         {
           "from": undefined,
           "location": [
             {
-              "pointer": "#/openapi",
-              "reportOnKey": true,
+              "pointer": "#/theme/openapi/layout",
+              "reportOnKey": false,
               "source": "",
             },
           ],
-          "message": "Property \`openapi\` is not expected here.",
+          "message": "\`layout\` can be one of the following only: "stacked", "three-panel".",
           "ruleId": "configuration spec",
           "severity": "error",
           "suggest": [],
@@ -163,15 +428,18 @@ describe('lint', () => {
           "from": undefined,
           "location": [
             {
-              "pointer": "#/apis",
-              "reportOnKey": false,
+              "pointer": "#/theme/openapi/theme/theme",
+              "reportOnKey": true,
               "source": "",
             },
           ],
-          "message": "Expected type \`ConfigApis\` (object) but got \`string\`",
+          "message": "Property \`theme\` is not expected here.",
           "ruleId": "configuration spec",
           "severity": "error",
-          "suggest": [],
+          "suggest": [
+            "schema",
+            "shape",
+          ],
         },
       ]
     `);
@@ -285,6 +553,920 @@ describe('lint', () => {
     `);
   });
 
+  it('lintConfig should detect wrong fields in the default configuration after merging with the portal config schema', async () => {
+    const document = testPortalConfig;
+    const results = await lintConfig({ document });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      [
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/licenseKey",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/sso/0",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "\`sso\` can be one of the following only: "REDOCLY", "CORPORATE", "GUEST".",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/not-listed-filed",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`not-listed-filed\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/redirects/some-redirect/t1o",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`t1o\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [
+            "to",
+            "type",
+          ],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/redirects/some-redirect/type",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`number\` but got \`string\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/seo/keywords",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`array\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/rbac/content/**/additionalProp",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/rbac/content/foo/additionalProp2",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/rbac/additional-property/something",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/responseHeaders/some-header",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`rootRedoclyConfigSchema.responseHeaders_additionalProperties\` (array) but got \`string\`",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/responseHeaders/some-header2/0",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`rootRedoclyConfigSchema.responseHeaders_additionalProperties_items\` (object) but got \`string\`",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/responseHeaders/some-header2/1",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "The field \`name\` must be present on this level.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/responseHeaders/some-header2/1/unexpected-property",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`unexpected-property\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/responseHeaders/some-header2/1/value",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/apis/without-root",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "The field \`root\` must be present on this level.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/apis/without-root/foo",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`foo\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [
+            "root",
+          ],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/apis/with-wrong-root/root",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/apis/with-theme/theme/openapi",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`object\` but got \`string\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/apis/with-theme/theme/not-expected",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`not-expected\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/ssoOnPrem/oidc/title",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/ssoOnPrem/oidc/defaultTeams/0",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/ssoOnPrem/oidc/configuration",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "The field \`authorization_endpoint\` must be present on this level.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/ssoOnPrem/oidc/configuration/token_endpoint",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/ssoOnPrem/oidc/authorizationRequestCustomParams/login_hint",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/ssoOnPrem/sso-config-schema-without-configurationUrl",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "The field \`clientId\` must be present on this level.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/ssoOnPrem/sso-config-schema-without-configurationUrl",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "The field \`configurationUrl\` must be present on this level.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/ssoOnPrem/basic/credentials/0",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "The field \`username\` must be present on this level.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/ssoOnPrem/basic/credentials/0/teams/0",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/developerOnboarding/wrong",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`wrong\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/developerOnboarding/adapters/0",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`APIGEE_X\` (object) but got \`string\`",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/developerOnboarding/adapters/1",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "The field \`organizationName\` must be present on this level.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/developerOnboarding/adapters/1",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "The field \`auth\` must be present on this level.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/developerOnboarding/adapters/1/type",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/developerOnboarding/adapters/2",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "The field \`organizationName\` must be present on this level.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/developerOnboarding/adapters/2/auth",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "The field \`tokenEndpoint\` must be present on this level.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/developerOnboarding/adapters/2/auth/clientId",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/developerOnboarding/adapters/2/auth/not-expected",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`not-expected\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/developerOnboarding/adapters/3/auth",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "The field \`serviceAccountPrivateKey\` must be present on this level.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/developerOnboarding/adapters/3/auth/serviceAccountEmail",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/i18n/locales/0/code",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/theme/footer/items/0/items/0/href",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`href\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/env/some-env/mockServer/off",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`boolean\` but got \`string\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/env/some-env/mockServer/not-expected",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`not-expected\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/env/some-env/apis/no-root",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "The field \`root\` must be present on this level.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/env/some-env/apis/wrong-root/root",
+              "reportOnKey": false,
+              "source": "",
+            },
+          ],
+          "message": "Expected type \`string\` but got \`integer\`.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+      ]
+    `);
+  });
+
+  it('lintConfig should alternate its behavior when supplied externalConfigTypes', async () => {
+    const document = testPortalConfig;
+    const results = await lintConfig({
+      document,
+      externalConfigTypes: createConfigTypes({
+        type: 'object',
+        properties: { theme: themeConfigSchema },
+        additionalProperties: false,
+      }),
+    });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      [
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/licenseKey",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`licenseKey\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/seo",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`seo\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/redirects",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`redirects\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/rbac",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`rbac\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/responseHeaders",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`responseHeaders\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/ssoOnPrem",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`ssoOnPrem\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/sso",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`sso\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/developerOnboarding",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`developerOnboarding\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/i18n",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`i18n\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/metadata",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`metadata\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/not-listed-filed",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`not-listed-filed\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/env",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`env\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/theme/footer/items/0/items/0/href",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`href\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/apis/without-root/foo",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`foo\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/apis/without-root/output",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`output\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/apis/with-wrong-root/root",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`root\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/apis/with-theme/root",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`root\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "from": undefined,
+          "location": [
+            {
+              "pointer": "#/apis/with-theme/theme",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "Property \`theme\` is not expected here.",
+          "ruleId": "configuration spec",
+          "severity": "error",
+          "suggest": [],
+        },
+      ]
+    `);
+  });
+
   it("'const' can have any type", async () => {
     const document = parseYamlToDocument(
       outdent`
diff --git a/packages/core/src/config/__tests__/load.test.ts b/packages/core/src/config/__tests__/load.test.ts
index a22ca62f..b79bbd56 100644
--- a/packages/core/src/config/__tests__/load.test.ts
+++ b/packages/core/src/config/__tests__/load.test.ts
@@ -137,6 +137,19 @@ describe('getConfig', () => {
           "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": [],
+        },
         {
           "from": {
             "pointer": "#/rules",
@@ -154,19 +167,6 @@ describe('getConfig', () => {
           "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": [],
-        },
       ]
     `);
   });