Repository: Redocly/redocly-cli (ts)

Issue:
Build-in rule to detect schemas with type mismatch
**Is your feature request related to a problem? Please describe.**

It's possible to define schemas of type object that contain the `items` field and vice versa. Such schemas don't make sense. 

![Image](https://github.com/user-attachments/assets/517a5863-1b45-4b34-ab3b-574a6bb11738)

**Describe the solution you'd like**

We need to add one more built-in rule that shows a warning for this case.

**Describe alternatives you've considered**

Doing nothing.





Pull request description:
feat: add schema type mismatch rule
Add a new built-in rule to detect schemas with type mismatches between object and items fields.

Fix #1885

* **New Rule Implementation**
  - Add `no-schema-type-mismatch.ts` to detect schemas with type mismatches between object and items fields.
  - Check if a schema of type `object` contains the `items` field.
  - Check if a schema of type `array` contains the `properties` field.
  - Show warnings for detected mismatches.

* **Testing**
  - Add `no-schema-type-mismatch.test.ts` to test the new rule.
  - Cover schemas with type `object` containing the `items` field and schemas with type `array` containing the `properties` field.
  - Include both valid and invalid cases.

* **Documentation**
  - Update `built-in-rules.md` to include the new rule `no-schema-type-mismatch`.
  - Add a description and examples of the new rule.



Patch:
diff --git a/.changeset/good-ducks-turn.md b/.changeset/good-ducks-turn.md
new file mode 100644
index 00000000..043be808
--- /dev/null
+++ b/.changeset/good-ducks-turn.md
@@ -0,0 +1,5 @@
+---
+"@redocly/openapi-core": minor
+---
+
+Added the `no-schema-type-mismatch` rule.
diff --git a/docs/rules/built-in-rules.md b/docs/rules/built-in-rules.md
index 0f71e058..3a7f9f05 100644
--- a/docs/rules/built-in-rules.md
+++ b/docs/rules/built-in-rules.md
@@ -82,6 +82,7 @@ The rules list is split into sections.
 - [no-invalid-media-type-examples](./oas/no-invalid-media-type-examples.md): Example request bodies must match the declared schema
 - [no-invalid-schema-examples](./oas/no-invalid-schema-examples.md): Schema examples must match declared types
 - [no-required-schema-properties-undefined](./oas/no-required-schema-properties-undefined.md): All properties marked as required must be defined
+- [no-schema-type-mismatch](./oas/no-schema-type-mismatch.md): Detects schemas with type mismatches between object and items fields, and array and properties fields.
 - [request-mime-type](./oas/request-mime-type.md): Configure allowed mime types for requests
 - [response-mime-type](./oas/response-mime-type.md): Configure allowed mime types for responses
 - [response-contains-header](./oas/response-contains-header.md): List headers that must be included with specific response types
diff --git a/docs/rules/oas/no-schema-type-mismatch.md b/docs/rules/oas/no-schema-type-mismatch.md
new file mode 100644
index 00000000..27a2c4e2
--- /dev/null
+++ b/docs/rules/oas/no-schema-type-mismatch.md
@@ -0,0 +1,112 @@
+---
+slug: /docs/cli/rules/oas/no-schema-type-mismatch
+---
+
+# no-schema-type-mismatch
+
+Ensures that a schema's structural properties match its declared `type`. In particular:
+
+- A schema of type `object` **must not** include an `items` field.
+- A schema of type `array` **must not** include a `properties` field.
+
+| OAS | Compatibility |
+| --- | ------------- |
+| 2.0 | ✅            |
+| 3.0 | ✅            |
+| 3.1 | ✅            |
+
+```mermaid
+flowchart TD
+  Schema -->|if type is object| CheckItems["'items' field exists?"]
+  Schema -->|if type is array| CheckProps["'properties' field exists?"]
+```
+
+## API design principles
+
+When designing an API schema, the defined `type` should be consistent with its structure:
+
+- **Objects** are collections of key/value pairs. They should be defined using `properties` (or additionalProperties) and must not use `items`.
+- **Arrays** are ordered lists of items and must use `items` to define their content. Including `properties` is invalid.
+
+This rule helps catch typos and misconfigurations early in your API definition.
+
+## Configuration
+
+| Option   | Type   | Description                                                                                   |
+| -------- | ------ | --------------------------------------------------------------------------------------------- |
+| severity | string | Possible values: `off`, `warn`, `error`. Default is `error` in the recommended configuration. |
+
+Example configuration:
+
+```yaml
+rules:
+  no-schema-type-mismatch: error
+```
+
+## Examples
+
+### Incorrect Examples
+
+#### Object type with an `items` field
+
+```yaml
+properties:
+  user:
+    type: object
+    properties:
+      id:
+        type: string
+    items:
+      type: number
+```
+
+_Error:_ An `object` type should not include an `items` field.
+
+#### Array type with a `properties` field
+
+```yaml
+properties:
+  tags:
+    type: array
+    properties:
+      name:
+        type: string
+```
+
+_Error:_ An `array` type should not include a `properties` field.
+
+### Correct Examples
+
+#### Object type with proper `properties`
+
+```yaml
+properties:
+  user:
+    type: object
+    properties:
+      id:
+        type: string
+      name:
+        type: string
+```
+
+#### Array type with proper `items`
+
+```yaml
+properties:
+  tags:
+    type: array
+    items:
+      type: string
+```
+
+## Related rules
+
+- [configurable rules](../configurable-rules.md)
+- [no-invalid-media-type-examples](./no-invalid-media-type-examples.md)
+- [no-invalid-parameter-examples](./no-invalid-parameter-examples.md)
+- [no-invalid-schema-examples](./no-invalid-schema-examples.md)
+
+## Resources
+
+- [Rule source](https://github.com/Redocly/redocly-cli/blob/main/packages/core/src/rules/common/no-schema-type-mismatch.ts)
diff --git a/packages/core/src/config/all.ts b/packages/core/src/config/all.ts
index dcc1a016..c9328b55 100644
--- a/packages/core/src/config/all.ts
+++ b/packages/core/src/config/all.ts
@@ -19,6 +19,7 @@ const all: PluginStyleguideConfig<'built-in'> = {
     'no-enum-type-mismatch': 'error',
     'no-unresolved-refs': 'error',
     'no-required-schema-properties-undefined': 'error',
+    'no-schema-type-mismatch': 'error',
     'operation-summary': 'error',
     'operation-operationId': 'error',
     'operation-operationId-unique': 'error',
@@ -75,6 +76,7 @@ const all: PluginStyleguideConfig<'built-in'> = {
     'no-enum-type-mismatch': 'error',
     'no-unresolved-refs': 'error',
     'no-required-schema-properties-undefined': 'error',
+    'no-schema-type-mismatch': 'error',
     'no-invalid-media-type-examples': 'error',
     'no-server-example.com': 'error',
     'no-server-trailing-slash': 'error',
@@ -141,6 +143,7 @@ const all: PluginStyleguideConfig<'built-in'> = {
     'no-enum-type-mismatch': 'error',
     'no-unresolved-refs': 'error',
     'no-required-schema-properties-undefined': 'error',
+    'no-schema-type-mismatch': 'error',
     'no-invalid-media-type-examples': 'error',
     'no-server-example.com': 'error',
     'no-server-trailing-slash': 'error',
diff --git a/packages/core/src/config/minimal.ts b/packages/core/src/config/minimal.ts
index 47e09754..91a9d2db 100644
--- a/packages/core/src/config/minimal.ts
+++ b/packages/core/src/config/minimal.ts
@@ -19,6 +19,7 @@ const minimal: PluginStyleguideConfig<'built-in'> = {
     'no-enum-type-mismatch': 'warn',
     'no-unresolved-refs': 'error',
     'no-required-schema-properties-undefined': 'off',
+    'no-schema-type-mismatch': 'off',
     'operation-summary': 'warn',
     'operation-operationId': 'warn',
     'operation-operationId-unique': 'warn',
@@ -66,6 +67,7 @@ const minimal: PluginStyleguideConfig<'built-in'> = {
     'no-enum-type-mismatch': 'warn',
     'no-unresolved-refs': 'error',
     'no-required-schema-properties-undefined': 'off',
+    'no-schema-type-mismatch': 'off',
     'no-invalid-media-type-examples': {
       severity: 'warn',
       allowAdditionalProperties: false,
@@ -126,6 +128,7 @@ const minimal: PluginStyleguideConfig<'built-in'> = {
     'no-enum-type-mismatch': 'warn',
     'no-unresolved-refs': 'error',
     'no-required-schema-properties-undefined': 'off',
+    'no-schema-type-mismatch': 'off',
     'no-invalid-media-type-examples': 'warn',
     'no-server-example.com': 'warn',
     'no-server-trailing-slash': 'error',
diff --git a/packages/core/src/config/recommended-strict.ts b/packages/core/src/config/recommended-strict.ts
index cc20c1dc..23fada42 100644
--- a/packages/core/src/config/recommended-strict.ts
+++ b/packages/core/src/config/recommended-strict.ts
@@ -19,6 +19,7 @@ const recommendedStrict: PluginStyleguideConfig<'built-in'> = {
     'no-enum-type-mismatch': 'error',
     'no-unresolved-refs': 'error',
     'no-required-schema-properties-undefined': 'off',
+    'no-schema-type-mismatch': 'error',
     'operation-summary': 'error',
     'operation-operationId': 'error',
     'operation-operationId-unique': 'error',
@@ -66,6 +67,7 @@ const recommendedStrict: PluginStyleguideConfig<'built-in'> = {
     'no-enum-type-mismatch': 'error',
     'no-unresolved-refs': 'error',
     'no-required-schema-properties-undefined': 'off',
+    'no-schema-type-mismatch': 'error',
     'no-invalid-media-type-examples': {
       severity: 'error',
       allowAdditionalProperties: false,
@@ -126,6 +128,7 @@ const recommendedStrict: PluginStyleguideConfig<'built-in'> = {
     'no-enum-type-mismatch': 'error',
     'no-unresolved-refs': 'error',
     'no-required-schema-properties-undefined': 'off',
+    'no-schema-type-mismatch': 'error',
     'no-invalid-media-type-examples': 'error',
     'no-server-example.com': 'error',
     'no-server-trailing-slash': 'error',
diff --git a/packages/core/src/config/recommended.ts b/packages/core/src/config/recommended.ts
index fa1e1f9c..a0ee5882 100644
--- a/packages/core/src/config/recommended.ts
+++ b/packages/core/src/config/recommended.ts
@@ -19,6 +19,7 @@ const recommended: PluginStyleguideConfig<'built-in'> = {
     'no-enum-type-mismatch': 'error',
     'no-unresolved-refs': 'error',
     'no-required-schema-properties-undefined': 'off',
+    'no-schema-type-mismatch': 'warn',
     'operation-summary': 'error',
     'operation-description': 'off',
     'operation-operationId': 'warn',
@@ -66,6 +67,7 @@ const recommended: PluginStyleguideConfig<'built-in'> = {
     'no-enum-type-mismatch': 'error',
     'no-unresolved-refs': 'error',
     'no-required-schema-properties-undefined': 'off',
+    'no-schema-type-mismatch': 'warn',
     'no-invalid-media-type-examples': {
       severity: 'warn',
       allowAdditionalProperties: false,
@@ -126,6 +128,7 @@ const recommended: PluginStyleguideConfig<'built-in'> = {
     'no-enum-type-mismatch': 'error',
     'no-unresolved-refs': 'error',
     'no-required-schema-properties-undefined': 'off',
+    'no-schema-type-mismatch': 'warn',
     'no-invalid-media-type-examples': 'warn',
     'no-server-example.com': 'warn',
     'no-server-trailing-slash': 'error',
diff --git a/packages/core/src/rules/common/no-schema-type-mismatch.ts b/packages/core/src/rules/common/no-schema-type-mismatch.ts
new file mode 100644
index 00000000..bbfefdc3
--- /dev/null
+++ b/packages/core/src/rules/common/no-schema-type-mismatch.ts
@@ -0,0 +1,24 @@
+import type { Oas3Schema } from '../../typings/openapi';
+import type { Oas2Schema } from '../../typings/swagger';
+import type { Oas2Rule, Oas3Rule } from '../../visitors';
+import type { UserContext } from '../../walk';
+
+export const NoSchemaTypeMismatch: Oas3Rule | Oas2Rule = () => {
+  return {
+    Schema(schema: Oas2Schema | Oas3Schema, { report, location }: UserContext) {
+      if (schema.type === 'object' && schema.items) {
+        report({
+          message: "Schema type mismatch: 'object' type should not contain 'items' field.",
+          location: location.child('items'),
+        });
+      }
+
+      if (schema.type === 'array' && schema.properties) {
+        report({
+          message: "Schema type mismatch: 'array' type should not contain 'properties' field.",
+          location: location.child('properties'),
+        });
+      }
+    },
+  };
+};
diff --git a/packages/core/src/rules/oas2/index.ts b/packages/core/src/rules/oas2/index.ts
index d931c4ab..de9ebd91 100644
--- a/packages/core/src/rules/oas2/index.ts
+++ b/packages/core/src/rules/oas2/index.ts
@@ -42,6 +42,7 @@ import { ScalarPropertyMissingExample } from '../common/scalar-property-missing-
 import { RequiredStringPropertyMissingMinLength } from '../common/required-string-property-missing-min-length';
 import { SpecStrictRefs } from '../common/spec-strict-refs';
 import { NoRequiredSchemaPropertiesUndefined } from '../common/no-required-schema-properties-undefined';
+import { NoSchemaTypeMismatch } from '../common/no-schema-type-mismatch';
 
 import type { Oas2Rule } from '../../visitors';
 import type { Oas2RuleSet } from '../../oas-types';
@@ -95,6 +96,7 @@ export const rules: Oas2RuleSet<'built-in'> = {
   'required-string-property-missing-min-length': RequiredStringPropertyMissingMinLength as Oas2Rule,
   'spec-strict-refs': SpecStrictRefs as Oas2Rule,
   'no-required-schema-properties-undefined': NoRequiredSchemaPropertiesUndefined as Oas2Rule,
+  'no-schema-type-mismatch': NoSchemaTypeMismatch as Oas2Rule,
 };
 
 export const preprocessors = {};
diff --git a/packages/core/src/rules/oas3/index.ts b/packages/core/src/rules/oas3/index.ts
index efb5b46b..ce191bad 100644
--- a/packages/core/src/rules/oas3/index.ts
+++ b/packages/core/src/rules/oas3/index.ts
@@ -54,6 +54,7 @@ import { SpecStrictRefs } from '../common/spec-strict-refs';
 import { ComponentNameUnique } from './component-name-unique';
 import { ArrayParameterSerialization } from './array-parameter-serialization';
 import { NoRequiredSchemaPropertiesUndefined } from '../common/no-required-schema-properties-undefined';
+import { NoSchemaTypeMismatch } from '../common/no-schema-type-mismatch';
 
 import type { Oas3RuleSet } from '../../oas-types';
 import type { Oas3Rule } from '../../visitors';
@@ -119,6 +120,7 @@ export const rules: Oas3RuleSet<'built-in'> = {
   'component-name-unique': ComponentNameUnique as Oas3Rule,
   'array-parameter-serialization': ArrayParameterSerialization,
   'no-required-schema-properties-undefined': NoRequiredSchemaPropertiesUndefined as Oas3Rule,
+  'no-schema-type-mismatch': NoSchemaTypeMismatch as Oas3Rule,
 };
 
 export const preprocessors = {};
diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts
index 46a74aa8..0265304a 100644
--- a/packages/core/src/types/redocly-yaml.ts
+++ b/packages/core/src/types/redocly-yaml.ts
@@ -1,8 +1,8 @@
 import { rootRedoclyConfigSchema } from '@redocly/config';
 import { listOf } from '.';
-import { omitObjectProps, pickObjectProps, isCustomRuleId } from '../utils';
-import { getNodeTypesFromJSONSchema } from './json-schema-adapter';
 import { SpecVersion, getTypes } from '../oas-types';
+import { isCustomRuleId, omitObjectProps, pickObjectProps } from '../utils';
+import { getNodeTypesFromJSONSchema } from './json-schema-adapter';
 
 import type { JSONSchema } from 'json-schema-to-ts';
 import type { NodeType } from '.';
@@ -48,6 +48,7 @@ const builtInOAS2Rules = [
   'spec-strict-refs',
   'no-unresolved-refs',
   'no-required-schema-properties-undefined',
+  'no-schema-type-mismatch',
   'boolean-parameter-prefixes',
   'request-mime-type',
   'response-contains-property',
@@ -96,6 +97,7 @@ const builtInOAS3Rules = [
   'spec-strict-refs',
   'no-unresolved-refs',
   'no-required-schema-properties-undefined',
+  'no-schema-type-mismatch',
   'boolean-parameter-prefixes',
   'component-name-unique',
   'no-empty-servers',


Test patch:
diff --git a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap
index 4b352021..79176f75 100644
--- a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap
+++ b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap
@@ -59,6 +59,7 @@ exports[`resolveConfig should ignore minimal from the root and read local file 1
     "no-invalid-schema-examples": "off",
     "no-path-trailing-slash": "error",
     "no-required-schema-properties-undefined": "off",
+    "no-schema-type-mismatch": "warn",
     "no-unresolved-refs": "error",
     "operation-2xx-response": "warn",
     "operation-4xx-response": "error",
@@ -114,6 +115,7 @@ exports[`resolveConfig should ignore minimal from the root and read local file 1
     "no-invalid-schema-examples": "off",
     "no-path-trailing-slash": "error",
     "no-required-schema-properties-undefined": "off",
+    "no-schema-type-mismatch": "warn",
     "no-server-example.com": "warn",
     "no-server-trailing-slash": "error",
     "no-server-variables-empty-enum": "error",
@@ -173,6 +175,7 @@ exports[`resolveConfig should ignore minimal from the root and read local file 1
     "no-invalid-schema-examples": "off",
     "no-path-trailing-slash": "error",
     "no-required-schema-properties-undefined": "off",
+    "no-schema-type-mismatch": "warn",
     "no-server-example.com": "warn",
     "no-server-trailing-slash": "error",
     "no-server-variables-empty-enum": "error",
@@ -285,6 +288,7 @@ exports[`resolveStyleguideConfig should resolve extends with local file config w
     "no-invalid-schema-examples": "off",
     "no-path-trailing-slash": "error",
     "no-required-schema-properties-undefined": "off",
+    "no-schema-type-mismatch": "warn",
     "no-unresolved-refs": "error",
     "operation-2xx-response": "error",
     "operation-4xx-response": "off",
@@ -340,6 +344,7 @@ exports[`resolveStyleguideConfig should resolve extends with local file config w
     "no-invalid-schema-examples": "off",
     "no-path-trailing-slash": "error",
     "no-required-schema-properties-undefined": "off",
+    "no-schema-type-mismatch": "warn",
     "no-server-example.com": "warn",
     "no-server-trailing-slash": "error",
     "no-server-variables-empty-enum": "error",
@@ -399,6 +404,7 @@ exports[`resolveStyleguideConfig should resolve extends with local file config w
     "no-invalid-schema-examples": "off",
     "no-path-trailing-slash": "error",
     "no-required-schema-properties-undefined": "off",
+    "no-schema-type-mismatch": "warn",
     "no-server-example.com": "warn",
     "no-server-trailing-slash": "error",
     "no-server-variables-empty-enum": "error",
diff --git a/packages/core/src/rules/common/__tests__/no-schema-type-mismatch.test.ts b/packages/core/src/rules/common/__tests__/no-schema-type-mismatch.test.ts
new file mode 100644
index 00000000..c18f40d1
--- /dev/null
+++ b/packages/core/src/rules/common/__tests__/no-schema-type-mismatch.test.ts
@@ -0,0 +1,126 @@
+import { outdent } from 'outdent';
+import { makeConfig, parseYamlToDocument, replaceSourceWithRef } from '../../../../__tests__/utils';
+import { lintDocument } from '../../../lint';
+import { BaseResolver } from '../../../resolve';
+
+describe('no-schema-type-mismatch rule', () => {
+  it('should report a warning for object type with items field', async () => {
+    const yaml = outdent`
+      openapi: 3.0.0
+      info:
+        title: Test API
+        version: 1.0.0
+      paths:
+        /test:
+          get:
+            responses:
+              '200':
+                description: OK
+                content:
+                  application/json:
+                    schema:
+                      type: object
+                      items:
+                        type: string
+    `;
+
+    const document = parseYamlToDocument(yaml, 'test.yaml');
+    const results = await lintDocument({
+      document,
+      externalRefResolver: new BaseResolver(),
+      config: await makeConfig({ rules: { 'no-schema-type-mismatch': 'warn' } }),
+    });
+
+    expect(replaceSourceWithRef(results)).toEqual([
+      {
+        location: [
+          {
+            pointer: '#/paths/~1test/get/responses/200/content/application~1json/schema/items',
+            reportOnKey: false,
+            source: 'test.yaml',
+          },
+        ],
+        message: "Schema type mismatch: 'object' type should not contain 'items' field.",
+        ruleId: 'no-schema-type-mismatch',
+        severity: 'warn',
+        suggest: [],
+      },
+    ]);
+  });
+
+  it('should report a warning for array type with properties field', async () => {
+    const yaml = outdent`
+      openapi: 3.0.0
+      info:
+        title: Test API
+        version: 1.0.0
+      paths:
+        /test:
+          get:
+            responses:
+              '200':
+                description: OK
+                content:
+                  application/json:
+                    schema:
+                      type: array
+                      properties:
+                        name:
+                          type: string
+    `;
+
+    const document = parseYamlToDocument(yaml, 'test.yaml');
+    const results = await lintDocument({
+      document,
+      externalRefResolver: new BaseResolver(),
+      config: await makeConfig({ rules: { 'no-schema-type-mismatch': 'warn' } }),
+    });
+
+    expect(replaceSourceWithRef(results)).toEqual([
+      {
+        location: [
+          {
+            pointer: '#/paths/~1test/get/responses/200/content/application~1json/schema/properties',
+            reportOnKey: false,
+            source: 'test.yaml',
+          },
+        ],
+        message: "Schema type mismatch: 'array' type should not contain 'properties' field.",
+        ruleId: 'no-schema-type-mismatch',
+        severity: 'warn',
+        suggest: [],
+      },
+    ]);
+  });
+
+  it('should not report a warning for valid schemas', async () => {
+    const yaml = outdent`
+      openapi: 3.0.0
+      info:
+        title: Test API
+        version: 1.0.0
+      paths:
+        /test:
+          get:
+            responses:
+              '200':
+                description: OK
+                content:
+                  application/json:
+                    schema:
+                      type: object
+                      properties:
+                        name:
+                          type: string
+    `;
+
+    const document = parseYamlToDocument(yaml, 'test.yaml');
+    const results = await lintDocument({
+      document,
+      externalRefResolver: new BaseResolver(),
+      config: await makeConfig({ rules: { 'no-schema-type-mismatch': 'warn' } }),
+    });
+
+    expect(replaceSourceWithRef(results)).toEqual([]);
+  });
+});