Repository: Redocly/redocly-cli
Pull request #1134 (2023-06-16 07:55:34)

Feature: Add `component-name-unique` rule
## What/Why/How?
Adds the following rule: `component-name-unique`

This will allow checking for unique component names for
- parameter
- requestBody
- response
- schema

This helps avoid problems with generators. For example if combining multiple specs into one where the same component names are used.

## Reference

## Testing
- [x] Add tests for each rule.

## Screenshots (optional)

## Check yourself

- [x] Code is linted
- [ ] Tested with redoc/reference-docs/workflows
- [x] All new/updated code is covered with tests

## Security

- [ ] Security impact of change has been considered
- [ ] Code follows company security practices and guidelines


tatomyr: Hi @pboos,

Thanks for your contribution! I'm curious if this could be one rule instead of four? I'm thinking of something like this:

```yaml
unique-names:
  parameter: error
  requestBody: warn
```

What do you think? 
cc @RomanHotsiy, @lornajane 
pboos: > Hi @pboos,
> 
> Thanks for your contribution! I'm curious if this could be one rule instead of four? I'm thinking of something like this:
> 
> ```yaml
> unique-names:
>   parameter: error
>   requestBody: warn
> ```
> 
> What do you think? cc @RomanHotsiy, @lornajane

@tatomyr that definitely would be possible. I originally had it that way locally but thought it might be nicer to separate them. Wasn't aware of this type of configuration being possible :). I will see if I can merge them and do that configuration. Should not be too hard to do (once I figure out how that works).
pboos: > I have a few ideas on how to potentially improve it:
> 
> * rename rules to somehow add `component` into them, not sure how to name them specifically though
> * create a single rule: `component-name-unique` which can accept type of components to enforce but should enforce all the components to be unique by default (isn't it what is usually needed?)
> * we can create a assertion so someone can author custom rules using `unique` assertion, see more here: https://redocly.com/docs/cli/rules/configurable-rules/
> 
> What do you think?

@RomanHotsiy thank you for the feedback! Very much appreciated.

I have updated the code now to just be one rule `component-name-unique` that allows configuration the severity for each component type. The only side effect is that the test file now is very huge as I test all the separate ones plus one additional combined case. But this might be acceptable.

Let me know what you think of the new code.


pboos: > Thanks for the update. Left a couple of comments.

@tatomyr Thank you for the comments. I have updated the PR accordingly and would be glad for a further review.
pboos: @RomanHotsiy Thank you for the comment. What else is needed to get this merged? :) As I can't runt he pipelines nor merge it myself :).
RomanHotsiy: @tatomyr let's review and merge it? It looks good to me 👍 

Patch:
diff --git a/docs/rules/component-name-unique.md b/docs/rules/component-name-unique.md
new file mode 100644
index 00000000..287f7f25
--- /dev/null
+++ b/docs/rules/component-name-unique.md
@@ -0,0 +1,109 @@
+# component-name-unique
+
+Verifies component names are unique.
+
+|OAS|Compatibility|
+|---|--|
+|2.0|❌|
+|3.0|✅|
+|3.1|✅|
+
+
+## API design principles
+
+When generating code based on an OpenAPI definition, there are various different problems when component names are not
+unique through the whole spec.
+
+- schema: The code generator creates a class for each schema.
+  If they are not uniquely named, the generator appends numbers. These numbers are non-deterministic.
+  By adding a new schema with the same component name it could change the name (appended number) of another one.
+- parameter: The code generator creates a class for each parameter.
+  If they are not uniquely named, the generator appends numbers. These numbers are non-deterministic.
+  By adding a new parameter with the same component name it could change the name (appended number) of another one.
+- response: The code generator tends to reuse the first one and drops the other ones with the same component name.
+- requestBody: The code generator tends to reuse the first one and drops the other ones with the same component name.
+
+This clearly is not optimal. Having unique component names prevents these problems.
+
+## Configuration
+
+| Option        |Type| Description                                                                              |
+|---------------|---|------------------------------------------------------------------------------------------|
+| severity      |string| Possible values: `off`, `warn`, `error`. Default `off` (in `recommended` configuration). |
+| schemas       |string| Possible values: `off`, `warn`, `error`. Default: not set. |
+| parameters    |string| Possible values: `off`, `warn`, `error`. Default: not set. |
+| responses     |string| Possible values: `off`, `warn`, `error`. Default: not set. |
+| requestBodies |string| Possible values: `off`, `warn`, `error`. Default: not set. |
+
+An example configuration:
+
+```yaml
+rules:
+  component-name-unique:
+    schemas: error
+    parameters: off
+    responses: warn
+    requestBodies: warn
+```
+
+## Examples
+
+
+Given this configuration:
+
+```yaml
+rules:
+  component-name-unique: error
+```
+
+### Example of **incorrect** schema files
+
+file1.yaml:
+```yaml
+components:
+  schemas:
+    FooSchema:
+      type: object
+      properties:
+        field:
+          $ref: './file2.yaml#/components/schemas/FooSchema'
+```
+
+file2.yaml:
+```yaml
+components:
+  schemas:
+    FooSchema:
+      type: object
+      properties:
+        otherField:
+          type: string
+```
+
+### Example of **correct** schema files
+
+file1.yaml:
+```yaml
+components:
+  schemas:
+    FooSchema:
+      type: object
+      properties:
+        field:
+          $ref: './file2.yaml#/components/schemas/AnotherFooSchema'
+```
+
+file2.yaml:
+```yaml
+components:
+  schemas:
+    AnotherFooSchema:
+      type: object
+      properties:
+        otherField:
+          type: string
+```
+
+## Relates rules
+
+- [no-unused-components](./no-unused-components.md)
diff --git a/packages/core/src/config/all.ts b/packages/core/src/config/all.ts
index 2903687e..3bb3e471 100644
--- a/packages/core/src/config/all.ts
+++ b/packages/core/src/config/all.ts
@@ -49,6 +49,7 @@ export default {
     'no-invalid-parameter-examples': 'error',
     'scalar-property-missing-example': 'error',
     'spec-strict-refs': 'error',
+    'component-name-unique': 'error',
   },
   oas3_0Rules: {
     'no-invalid-media-type-examples': 'error',
diff --git a/packages/core/src/config/minimal.ts b/packages/core/src/config/minimal.ts
index 16d52e76..df194c68 100644
--- a/packages/core/src/config/minimal.ts
+++ b/packages/core/src/config/minimal.ts
@@ -32,6 +32,7 @@ export default {
     'paths-kebab-case': 'off',
     spec: 'error',
     'spec-strict-refs': 'off',
+    'component-name-unique': 'off',
   },
   oas3_0Rules: {
     'no-invalid-media-type-examples': {
diff --git a/packages/core/src/config/recommended.ts b/packages/core/src/config/recommended.ts
index 9e6f9a6a..3456f8b2 100644
--- a/packages/core/src/config/recommended.ts
+++ b/packages/core/src/config/recommended.ts
@@ -32,6 +32,7 @@ export default {
     'paths-kebab-case': 'off',
     spec: 'error',
     'spec-strict-refs': 'off',
+    'component-name-unique': 'off',
   },
   oas3_0Rules: {
     'no-invalid-media-type-examples': {
diff --git a/packages/core/src/rules/oas3/component-name-unique.ts b/packages/core/src/rules/oas3/component-name-unique.ts
new file mode 100644
index 00000000..6f8df480
--- /dev/null
+++ b/packages/core/src/rules/oas3/component-name-unique.ts
@@ -0,0 +1,158 @@
+import { Problem, UserContext } from '../../walk';
+import { Oas2Rule, Oas3Rule, Oas3Visitor } from '../../visitors';
+import {
+  Oas3Definition,
+  Oas3Parameter,
+  Oas3RequestBody,
+  Oas3Response,
+  Oas3Schema,
+  OasRef,
+} from '../../typings/openapi';
+
+const TYPE_NAME_SCHEMA = 'Schema';
+const TYPE_NAME_PARAMETER = 'Parameter';
+const TYPE_NAME_RESPONSE = 'Response';
+const TYPE_NAME_REQUEST_BODY = 'RequestBody';
+
+const TYPE_NAME_TO_OPTION_COMPONENT_NAME: { [key: string]: string } = {
+  [TYPE_NAME_SCHEMA]: 'schemas',
+  [TYPE_NAME_PARAMETER]: 'parameters',
+  [TYPE_NAME_RESPONSE]: 'responses',
+  [TYPE_NAME_REQUEST_BODY]: 'requestBodies',
+};
+
+export const ComponentNameUnique: Oas3Rule | Oas2Rule = (options) => {
+  const components = new Map<string, Set<string>>();
+
+  const typeNames: string[] = [];
+  if (options.schemas !== 'off') {
+    typeNames.push(TYPE_NAME_SCHEMA);
+  }
+  if (options.parameters !== 'off') {
+    typeNames.push(TYPE_NAME_PARAMETER);
+  }
+  if (options.responses !== 'off') {
+    typeNames.push(TYPE_NAME_RESPONSE);
+  }
+  if (options.requestBodies !== 'off') {
+    typeNames.push(TYPE_NAME_REQUEST_BODY);
+  }
+
+  const rule: Oas3Visitor = {
+    ref: {
+      leave(ref: OasRef, { type, resolve }: UserContext) {
+        const typeName = type.name;
+        if (typeNames.includes(typeName)) {
+          const resolvedRef = resolve(ref);
+          if (!resolvedRef.location) return;
+
+          addComponentFromAbsoluteLocation(
+            typeName,
+            resolvedRef.location.absolutePointer.toString()
+          );
+        }
+      },
+    },
+    Root: {
+      leave(root: Oas3Definition, ctx: UserContext) {
+        components.forEach((value, key, _) => {
+          if (value.size > 1) {
+            const component = getComponentFromKey(key);
+            const optionComponentName = getOptionComponentNameForTypeName(component.typeName);
+            const definitions = Array.from(value)
+              .map((v) => `- ${v}`)
+              .join('\n');
+
+            const problem: Problem = {
+              message: `Component '${optionComponentName}/${component.componentName}' is not unique. It is defined at:\n${definitions}`,
+            };
+
+            const componentSeverity = optionComponentName ? options[optionComponentName] : null;
+            if (componentSeverity) {
+              problem.forceSeverity = componentSeverity;
+            }
+            ctx.report(problem);
+          }
+        });
+      },
+    },
+  };
+
+  if (options.schemas != 'off') {
+    rule.NamedSchemas = {
+      Schema(_: Oas3Schema, { location }: UserContext) {
+        addComponentFromAbsoluteLocation(TYPE_NAME_SCHEMA, location.absolutePointer.toString());
+      },
+    };
+  }
+
+  if (options.responses != 'off') {
+    rule.NamedResponses = {
+      Response(_: Oas3Response, { location }: UserContext) {
+        addComponentFromAbsoluteLocation(TYPE_NAME_RESPONSE, location.absolutePointer.toString());
+      },
+    };
+  }
+
+  if (options.parameters != 'off') {
+    rule.NamedParameters = {
+      Parameter(_: Oas3Parameter, { location }: UserContext) {
+        addComponentFromAbsoluteLocation(TYPE_NAME_PARAMETER, location.absolutePointer.toString());
+      },
+    };
+  }
+
+  if (options.requestBodies != 'off') {
+    rule.NamedRequestBodies = {
+      RequestBody(_: Oas3RequestBody, { location }: UserContext) {
+        addComponentFromAbsoluteLocation(
+          TYPE_NAME_REQUEST_BODY,
+          location.absolutePointer.toString()
+        );
+      },
+    };
+  }
+
+  return rule;
+
+  function getComponentNameFromAbsoluteLocation(absoluteLocation: string): string {
+    const componentName = absoluteLocation.split('/').slice(-1)[0];
+    if (
+      componentName.endsWith('.yml') ||
+      componentName.endsWith('.yaml') ||
+      componentName.endsWith('.json')
+    ) {
+      return componentName.slice(0, componentName.lastIndexOf('.'));
+    }
+    return componentName;
+  }
+
+  function addFoundComponent(
+    typeName: string,
+    componentName: string,
+    absoluteLocation: string
+  ): void {
+    const key = getKeyForComponent(typeName, componentName);
+    const locations = components.get(key) ?? new Set();
+    locations.add(absoluteLocation);
+    components.set(key, locations);
+  }
+
+  function addComponentFromAbsoluteLocation(typeName: string, absoluteLocation: string): void {
+    const componentName = getComponentNameFromAbsoluteLocation(absoluteLocation);
+    addFoundComponent(typeName, componentName, absoluteLocation);
+  }
+};
+
+function getOptionComponentNameForTypeName(typeName: string): string | null {
+  return TYPE_NAME_TO_OPTION_COMPONENT_NAME[typeName] ?? null;
+}
+
+function getKeyForComponent(typeName: string, componentName: string): string {
+  return `${typeName}/${componentName}`;
+}
+
+function getComponentFromKey(key: string): { typeName: string; componentName: string } {
+  const [typeName, componentName] = key.split('/');
+  return { typeName, componentName };
+}
diff --git a/packages/core/src/rules/oas3/index.ts b/packages/core/src/rules/oas3/index.ts
index 49d77628..b03f6724 100644
--- a/packages/core/src/rules/oas3/index.ts
+++ b/packages/core/src/rules/oas3/index.ts
@@ -51,6 +51,7 @@ import { SpecComponentsInvalidMapName } from './spec-components-invalid-map-name
 import { Operation4xxProblemDetailsRfc7807 } from './operation-4xx-problem-details-rfc7807';
 import { RequiredStringPropertyMissingMinLength } from '../common/required-string-property-missing-min-length';
 import { SpecStrictRefs } from '../common/spec-strict-refs';
+import { ComponentNameUnique } from './component-name-unique';
 
 export const rules = {
   spec: OasSpec,
@@ -106,6 +107,7 @@ export const rules = {
   'spec-components-invalid-map-name': SpecComponentsInvalidMapName,
   'required-string-property-missing-min-length': RequiredStringPropertyMissingMinLength,
   'spec-strict-refs': SpecStrictRefs,
+  'component-name-unique': ComponentNameUnique,
 } as Oas3RuleSet;
 
 export const preprocessors = {};
diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts
index ec865477..e2206b4f 100644
--- a/packages/core/src/types/redocly-yaml.ts
+++ b/packages/core/src/types/redocly-yaml.ts
@@ -67,6 +67,7 @@ const builtInRulesList = [
   'spec-components-invalid-map-name',
   'required-string-property-missing-min-length',
   'spec-strict-refs',
+  'component-name-unique',
 ];
 const nodeTypesList = [
   'any',


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 41cb915d..ae0b117a 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
@@ -38,6 +38,7 @@ Object {
   "rules": Object {
     "assertions": "warn",
     "boolean-parameter-prefixes": "error",
+    "component-name-unique": "off",
     "info-contact": "off",
     "info-license": "warn",
     "info-license-url": "warn",
@@ -128,6 +129,7 @@ Object {
       },
     ],
     "boolean-parameter-prefixes": "error",
+    "component-name-unique": "off",
     "info-contact": "off",
     "info-license": "warn",
     "info-license-url": "warn",
diff --git a/packages/core/src/rules/oas3/__tests__/component-name-unique.test.ts b/packages/core/src/rules/oas3/__tests__/component-name-unique.test.ts
new file mode 100644
index 00000000..05eb35a3
--- /dev/null
+++ b/packages/core/src/rules/oas3/__tests__/component-name-unique.test.ts
@@ -0,0 +1,823 @@
+import { outdent } from 'outdent';
+import { parseYamlToDocument, replaceSourceWithRef } from '../../../../__tests__/utils';
+import { lintDocumentForTest } from './utils/lint-document-for-test';
+
+describe('Oas3 component-name-unique', () => {
+  describe('schema', () => {
+    it('should report on multiple schemas with same name', async () => {
+      const document = parseYamlToDocument(
+        outdent`
+          openapi: 3.0.0
+          components:
+            schemas:
+              SomeSchema:
+                type: object
+              Test:
+                type: object
+                properties:
+                  there:
+                    $ref: '/test.yaml#/components/schemas/SomeSchema'
+        `,
+        '/foobar.yaml'
+      );
+      const additionalDocuments = [
+        {
+          absoluteRef: '/test.yaml',
+          body: outdent`
+          openapi: 3.0.0
+          components:
+            schemas:
+              SomeSchema:
+                type: object
+        `,
+        },
+      ];
+
+      const results = await lintDocumentForTest(
+        { 'component-name-unique': 'error' },
+        document,
+        additionalDocuments
+      );
+
+      expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+        Array [
+          Object {
+            "location": Array [
+              Object {
+                "pointer": "#/",
+                "reportOnKey": false,
+                "source": "/foobar.yaml",
+              },
+            ],
+            "message": "Component 'schemas/SomeSchema' is not unique. It is defined at:
+        - /foobar.yaml#/components/schemas/SomeSchema
+        - /test.yaml#/components/schemas/SomeSchema",
+            "ruleId": "component-name-unique",
+            "severity": "error",
+            "suggest": Array [],
+          },
+        ]
+      `);
+    });
+
+    it('should report on multiple schemas with same name - filename', async () => {
+      const document = parseYamlToDocument(
+        outdent`
+        openapi: 3.0.0
+        components:
+          schemas:
+            SomeSchema:
+              type: object
+            Test:
+              type: object
+              properties:
+                there:
+                  $ref: '/SomeSchema.yaml'
+      `,
+        '/foobar.yaml'
+      );
+      const additionalDocuments = [
+        {
+          absoluteRef: '/SomeSchema.yaml',
+          body: outdent`
+          type: object
+          properties:
+            test:
+              type: number
+      `,
+        },
+      ];
+
+      const results = await lintDocumentForTest(
+        { 'component-name-unique': 'error' },
+        document,
+        additionalDocuments
+      );
+
+      expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/",
+              "reportOnKey": false,
+              "source": "/foobar.yaml",
+            },
+          ],
+          "message": "Component 'schemas/SomeSchema' is not unique. It is defined at:
+      - /foobar.yaml#/components/schemas/SomeSchema
+      - /SomeSchema.yaml",
+          "ruleId": "component-name-unique",
+          "severity": "error",
+          "suggest": Array [],
+        },
+      ]
+    `);
+    });
+
+    it('should not report on multiple schemas with different names', async () => {
+      const document = parseYamlToDocument(
+        outdent`
+          openapi: 3.0.0
+          components:
+            schemas:
+              SomeSchema:
+                type: object
+              Test:
+                type: object
+                properties:
+                  there:
+                    $ref: '/test.yaml#/components/schemas/OtherSchema'
+        `,
+        '/foobar.yaml'
+      );
+      const additionalDocuments = [
+        {
+          absoluteRef: '/test.yaml',
+          body: outdent`
+          openapi: 3.0.0
+          components:
+            schemas:
+              OtherSchema:
+                type: object
+        `,
+        },
+      ];
+
+      const results = await lintDocumentForTest(
+        { 'component-name-unique': 'error' },
+        document,
+        additionalDocuments
+      );
+
+      expect(replaceSourceWithRef(results)).toMatchInlineSnapshot('Array []');
+    });
+  });
+
+  describe('parameter', () => {
+    it('should report if multiple parameters have same component name', async () => {
+      const document = parseYamlToDocument(
+        outdent`
+        openapi: 3.0.0
+        paths:
+          /test:
+            get:
+              parameters:
+              - $ref: '#/components/parameters/ParameterOne'
+              - $ref: '/test.yaml#/components/parameters/ParameterOne'
+        components:
+          parameters:
+            ParameterOne:
+              name: parameterOne
+              in: query
+              schema:
+                type: integer
+      `,
+        '/foobar.yaml'
+      );
+      const additionalDocuments = [
+        {
+          absoluteRef: '/test.yaml',
+          body: outdent`
+        openapi: 3.0.0
+        components:
+          parameters:
+            ParameterOne:
+              name: oneParameter
+              in: query
+              schema:
+                type: integer
+        `,
+        },
+      ];
+
+      const results = await lintDocumentForTest(
+        { 'component-name-unique': 'error' },
+        document,
+        additionalDocuments
+      );
+
+      expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/",
+              "reportOnKey": false,
+              "source": "/foobar.yaml",
+            },
+          ],
+          "message": "Component 'parameters/ParameterOne' is not unique. It is defined at:
+      - /foobar.yaml#/components/parameters/ParameterOne
+      - /test.yaml#/components/parameters/ParameterOne",
+          "ruleId": "component-name-unique",
+          "severity": "error",
+          "suggest": Array [],
+        },
+      ]
+    `);
+    });
+
+    it('should report on multiple parameters with same component name - filename', async () => {
+      const document = parseYamlToDocument(
+        outdent`
+        openapi: 3.0.0
+        paths:
+          /test:
+            get:
+              parameters:
+              - $ref: '#/components/parameters/ParameterOne'
+              - $ref: '/ParameterOne.yaml'
+        components:
+          parameters:
+            ParameterOne:
+              name: parameterOne
+              in: query
+              schema:
+                type: integer
+      `,
+        '/foobar.yaml'
+      );
+      const additionalDocuments = [
+        {
+          absoluteRef: '/ParameterOne.yaml',
+          body: outdent`
+          name: oneParameter
+          in: query
+          schema:
+            type: integer
+        `,
+        },
+      ];
+
+      const results = await lintDocumentForTest(
+        { 'component-name-unique': 'error' },
+        document,
+        additionalDocuments
+      );
+
+      expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/",
+              "reportOnKey": false,
+              "source": "/foobar.yaml",
+            },
+          ],
+          "message": "Component 'parameters/ParameterOne' is not unique. It is defined at:
+      - /foobar.yaml#/components/parameters/ParameterOne
+      - /ParameterOne.yaml",
+          "ruleId": "component-name-unique",
+          "severity": "error",
+          "suggest": Array [],
+        },
+      ]
+    `);
+    });
+
+    it('should not report on multiple parameters with different component names', async () => {
+      const document = parseYamlToDocument(
+        outdent`
+        openapi: 3.0.0
+        paths:
+          /test:
+            get:
+              parameters:
+              - $ref: '#/components/parameters/ParameterOne'
+              - $ref: '/test.yaml#/components/parameters/OneParameter'
+        components:
+          parameters:
+            ParameterOne:
+              name: parameterOne
+              in: query
+              schema:
+                type: integer
+      `,
+        '/foobar.yaml'
+      );
+      const additionalDocuments = [
+        {
+          absoluteRef: '/test.yaml',
+          body: outdent`
+        openapi: 3.0.0
+        components:
+          parameters:
+            OneParameter:
+              name: oneParameter
+              in: query
+              schema:
+                type: integer
+      `,
+        },
+      ];
+
+      const results = await lintDocumentForTest(
+        { 'component-name-unique': 'error' },
+        document,
+        additionalDocuments
+      );
+
+      expect(replaceSourceWithRef(results)).toMatchInlineSnapshot('Array []');
+    });
+  });
+
+  describe('response', () => {
+    it('should report if multiple responses have same component name', async () => {
+      const document = parseYamlToDocument(
+        outdent`
+        openapi: 3.0.0
+        paths:
+          /test:
+            get:
+              responses:
+                '200':
+                  $ref: '#/components/responses/SuccessResponse'
+          /test2:
+            get:
+              responses:
+                '200':
+                  $ref: '/test.yaml#/components/responses/SuccessResponse'
+        components:
+          responses:
+            SuccessResponse:
+              description: Successful response
+              content:
+                application/json:
+                  schema:
+                    type: string
+      `,
+        '/foobar.yaml'
+      );
+      const additionalDocuments = [
+        {
+          absoluteRef: '/test.yaml',
+          body: outdent`
+        openapi: 3.0.0
+        components:
+          responses:
+            SuccessResponse:
+              description: Successful response
+              content:
+                application/json:
+                  schema:
+                    type: string
+        `,
+        },
+      ];
+
+      const results = await lintDocumentForTest(
+        { 'component-name-unique': 'error' },
+        document,
+        additionalDocuments
+      );
+
+      expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/",
+              "reportOnKey": false,
+              "source": "/foobar.yaml",
+            },
+          ],
+          "message": "Component 'responses/SuccessResponse' is not unique. It is defined at:
+      - /foobar.yaml#/components/responses/SuccessResponse
+      - /test.yaml#/components/responses/SuccessResponse",
+          "ruleId": "component-name-unique",
+          "severity": "error",
+          "suggest": Array [],
+        },
+      ]
+    `);
+    });
+
+    it('should report on multiple responses with same component name - filename', async () => {
+      const document = parseYamlToDocument(
+        outdent`
+        openapi: 3.0.0
+        paths:
+          /test:
+            get:
+              responses:
+                '200':
+                  $ref: '#/components/responses/SuccessResponse'
+          /test2:
+            get:
+              responses:
+                '200':
+                  $ref: '/SuccessResponse.yaml'
+        components:
+          responses:
+            SuccessResponse:
+              description: Successful response
+              content:
+                application/json:
+                  schema:
+                    type: string
+      `,
+        '/foobar.yaml'
+      );
+      const additionalDocuments = [
+        {
+          absoluteRef: '/SuccessResponse.yaml',
+          body: outdent`
+          description: Successful response
+          content:
+            application/json:
+              schema:
+                type: string
+        `,
+        },
+      ];
+
+      const results = await lintDocumentForTest(
+        { 'component-name-unique': 'error' },
+        document,
+        additionalDocuments
+      );
+
+      expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/",
+              "reportOnKey": false,
+              "source": "/foobar.yaml",
+            },
+          ],
+          "message": "Component 'responses/SuccessResponse' is not unique. It is defined at:
+      - /foobar.yaml#/components/responses/SuccessResponse
+      - /SuccessResponse.yaml",
+          "ruleId": "component-name-unique",
+          "severity": "error",
+          "suggest": Array [],
+        },
+      ]
+    `);
+    });
+
+    it('should not report on multiple responses with different component names', async () => {
+      const document = parseYamlToDocument(
+        outdent`
+        openapi: 3.0.0
+        paths:
+          /test:
+            get:
+              responses:
+                '200':
+                  $ref: '#/components/responses/TestSuccessResponse'
+          /test2:
+            get:
+              responses:
+                '200':
+                  $ref: '/test.yaml#/components/responses/Test2SuccessResponse'
+        components:
+          responses:
+            TestSuccessResponse:
+              description: Successful response
+              content:
+                application/json:
+                  schema:
+                    type: string
+      `,
+        '/foobar.yaml'
+      );
+      const additionalDocuments = [
+        {
+          absoluteRef: '/test.yaml',
+          body: outdent`
+        openapi: 3.0.0
+        components:
+          responses:
+            Test2SuccessResponse:
+              description: Successful response
+              content:
+                application/json:
+                  schema:
+                    type: string
+      `,
+        },
+      ];
+
+      const results = await lintDocumentForTest(
+        { 'component-name-unique': 'error' },
+        document,
+        additionalDocuments
+      );
+
+      expect(replaceSourceWithRef(results)).toMatchInlineSnapshot('Array []');
+    });
+  });
+
+  describe('request-body', () => {
+    it('should report if multiple request bodies have same component name', async () => {
+      const document = parseYamlToDocument(
+        outdent`
+        openapi: 3.0.0
+        paths:
+          /test:
+            post:
+              requestBody:
+                $ref: '#/components/requestBodies/MyRequestBody'
+          /test2:
+            post:
+              requestBody:
+                $ref: '/test.yaml#/components/requestBodies/MyRequestBody'
+        components:
+          requestBodies:
+            MyRequestBody:
+              required: true
+              content:
+                application/json:
+                  schema:
+                    type: string
+      `,
+        '/foobar.yaml'
+      );
+      const additionalDocuments = [
+        {
+          absoluteRef: '/test.yaml',
+          body: outdent`
+        components:
+          requestBodies:
+            MyRequestBody:
+              required: true
+              content:
+                application/json:
+                  schema:
+                    type: string
+        `,
+        },
+      ];
+
+      const results = await lintDocumentForTest(
+        { 'component-name-unique': 'error' },
+        document,
+        additionalDocuments
+      );
+
+      expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/",
+              "reportOnKey": false,
+              "source": "/foobar.yaml",
+            },
+          ],
+          "message": "Component 'requestBodies/MyRequestBody' is not unique. It is defined at:
+      - /foobar.yaml#/components/requestBodies/MyRequestBody
+      - /test.yaml#/components/requestBodies/MyRequestBody",
+          "ruleId": "component-name-unique",
+          "severity": "error",
+          "suggest": Array [],
+        },
+      ]
+    `);
+    });
+
+    it('should report on multiple responses with same component name - filename', async () => {
+      const document = parseYamlToDocument(
+        outdent`
+        openapi: 3.0.0
+        paths:
+          /test:
+            post:
+              requestBody:
+                $ref: '#/components/requestBodies/MyRequestBody'
+          /test2:
+            post:
+              requestBody:
+                $ref: '/MyRequestBody.yaml'
+        components:
+          requestBodies:
+            MyRequestBody:
+              required: true
+              content:
+                application/json:
+                  schema:
+                    type: string
+      `,
+        '/foobar.yaml'
+      );
+      const additionalDocuments = [
+        {
+          absoluteRef: '/MyRequestBody.yaml',
+          body: outdent`
+          components:
+            requestBodies:
+              MyRequestBody:
+                required: true
+                content:
+                  application/json:
+                    schema:
+                      type: string
+        `,
+        },
+      ];
+
+      const results = await lintDocumentForTest(
+        { 'component-name-unique': 'error' },
+        document,
+        additionalDocuments
+      );
+
+      expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/",
+              "reportOnKey": false,
+              "source": "/foobar.yaml",
+            },
+          ],
+          "message": "Component 'requestBodies/MyRequestBody' is not unique. It is defined at:
+      - /foobar.yaml#/components/requestBodies/MyRequestBody
+      - /MyRequestBody.yaml",
+          "ruleId": "component-name-unique",
+          "severity": "error",
+          "suggest": Array [],
+        },
+      ]
+    `);
+    });
+
+    it('should not report on multiple responses with different component names', async () => {
+      const document = parseYamlToDocument(
+        outdent`
+        openapi: 3.0.0
+        paths:
+          /test:
+            post:
+              requestBody:
+                $ref: '#/components/requestBodies/TestRequestBody'
+          /test2:
+            post:
+              requestBody:
+                $ref: '/test.yaml#/components/requestBodies/Test2RequestBody'
+        components:
+          requestBodies:
+            TestRequestBody:
+              required: true
+              content:
+                application/json:
+                  schema:
+                    type: string
+      `,
+        '/foobar.yaml'
+      );
+      const additionalDocuments = [
+        {
+          absoluteRef: '/test.yaml',
+          body: outdent`
+        components:
+          requestBodies:
+            Test2RequestBody:
+              required: true
+              content:
+                application/json:
+                  schema:
+                    type: string
+      `,
+        },
+      ];
+
+      const results = await lintDocumentForTest(
+        { 'component-name-unique': 'error' },
+        document,
+        additionalDocuments
+      );
+
+      expect(replaceSourceWithRef(results)).toMatchInlineSnapshot('Array []');
+    });
+  });
+
+  describe('different severities', () => {
+    const document = parseYamlToDocument(
+      outdent`
+        openapi: 3.0.0
+        paths:
+          /test:
+            post:
+              requestBody:
+                $ref: '#/components/requestBodies/MyRequestBody'
+          /test2:
+            post:
+              requestBody:
+                $ref: '/test.yaml#/components/requestBodies/MyRequestBody'
+        components:
+          schemas:
+            SomeSchema:
+              type: object
+            Test:
+              type: object
+              properties:
+                there:
+                  $ref: '/test.yaml#/components/schemas/SomeSchema'
+          requestBodies:
+            MyRequestBody:
+              required: true
+              content:
+                application/json:
+                  schema:
+                    type: string
+      `,
+      '/foobar.yaml'
+    );
+    const additionalDocuments = [
+      {
+        absoluteRef: '/test.yaml',
+        body: outdent`
+        components:
+          schemas:
+            SomeSchema:
+              type: object
+          requestBodies:
+            MyRequestBody:
+              required: true
+              content:
+                application/json:
+                  schema:
+                    type: string
+        `,
+      },
+    ];
+
+    it('should report both schema and request body', async () => {
+      const results = await lintDocumentForTest(
+        { 'component-name-unique': 'error' },
+        document,
+        additionalDocuments
+      );
+
+      expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/",
+              "reportOnKey": false,
+              "source": "/foobar.yaml",
+            },
+          ],
+          "message": "Component 'requestBodies/MyRequestBody' is not unique. It is defined at:
+      - /foobar.yaml#/components/requestBodies/MyRequestBody
+      - /test.yaml#/components/requestBodies/MyRequestBody",
+          "ruleId": "component-name-unique",
+          "severity": "error",
+          "suggest": Array [],
+        },
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/",
+              "reportOnKey": false,
+              "source": "/foobar.yaml",
+            },
+          ],
+          "message": "Component 'schemas/SomeSchema' is not unique. It is defined at:
+      - /foobar.yaml#/components/schemas/SomeSchema
+      - /test.yaml#/components/schemas/SomeSchema",
+          "ruleId": "component-name-unique",
+          "severity": "error",
+          "suggest": Array [],
+        },
+      ]
+    `);
+    });
+
+    it('should not report if severity is off for specific component type', async () => {
+      const results = await lintDocumentForTest(
+        { 'component-name-unique': { severity: 'error', schemas: 'off' } },
+        document,
+        additionalDocuments
+      );
+
+      expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/",
+              "reportOnKey": false,
+              "source": "/foobar.yaml",
+            },
+          ],
+          "message": "Component 'requestBodies/MyRequestBody' is not unique. It is defined at:
+      - /foobar.yaml#/components/requestBodies/MyRequestBody
+      - /test.yaml#/components/requestBodies/MyRequestBody",
+          "ruleId": "component-name-unique",
+          "severity": "error",
+          "suggest": Array [],
+        },
+      ]
+    `);
+    });
+  });
+});
diff --git a/packages/core/src/rules/oas3/__tests__/utils/lint-document-for-test.ts b/packages/core/src/rules/oas3/__tests__/utils/lint-document-for-test.ts
new file mode 100644
index 00000000..b13266be
--- /dev/null
+++ b/packages/core/src/rules/oas3/__tests__/utils/lint-document-for-test.ts
@@ -0,0 +1,23 @@
+import { BaseResolver, Document } from '../../../../resolve';
+import { makeConfig, parseYamlToDocument } from '../../../../../__tests__/utils';
+import { lintDocument } from '../../../../lint';
+import { RuleConfig } from '../../../../config';
+
+export async function lintDocumentForTest(
+  rules: Record<string, RuleConfig>,
+  document: Document,
+  additionalDocuments: { absoluteRef: string; body: string }[]
+) {
+  const baseResolver = new BaseResolver();
+  additionalDocuments.forEach((item) =>
+    baseResolver.cache.set(
+      item.absoluteRef,
+      Promise.resolve(parseYamlToDocument(item.body, item.absoluteRef))
+    )
+  );
+  return await lintDocument({
+    externalRefResolver: baseResolver,
+    document,
+    config: await makeConfig(rules),
+  });
+}