Repository: Redocly/redocly-cli (ts)

Issue:
remove-unused-components removes components where a child object is referenced
**Describe the bug**

Given an OpenAPI document that contains something along these lines:

```
openapi: "3.0.0"
paths:
  /pets:
    get:
      summary: List all pets
      operationId: listPets
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Used'
components:
  schemas:
    InnerUsed:
      type: object
      properties:
        link:
          type: string
    Used:
      type: object
      properties:
        link:
          $ref: '#/components/schemas/InnerUsed/properties/link'
```

It would be expected that `bundle --remove-unused-components` would **not** remove the `InnerUsed` schema. It is, in fact, used.

However, the current behavior is that after running `bundle` with `--removed-unused-components`, `InnerUsed` is removed and the resulting document is invalid since the ref can no longer be resolved.

**To Reproduce**
Steps to reproduce the behavior:

1. Copy the OpenAPI content above to a file.
2. Run `redocly bundle <file.yaml> -o output.yaml --remove-unused-components`
4. Check the output.yaml file.

**Expected behavior**

In this simple case, the input and output should be the same. In a more complex case, _other_ components could be removed, just not this one.

**Logs**

Not applicable.

**OpenAPI definition**

Have only tested with 3.0.

**Redocly Version(s)**

1.0.0 and main.

**`Node.js` Version(s)**

16.17.0, 16.18.0, and 18.16.0


Pull request description:
fix: do not remove components which are only used via a reference to a child object
…a child object. fixes #1207

## What/Why/How?

Core problem is described in #1207 

## Reference

Fixes #1207

## Testing

I added a test case for this. I'm not 100% sure that this is the best location for this test but it seemed natural to me.

I've also tested this on a non-trivial OpenAPI document that has dozens of these cases.

## Screenshots (optional)

## Check yourself

- [X] Code is linted
- [ ] Tested with redoc/reference-docs/workflows *(What does this mean? It's not mentioned in the CONTRIBUTING.md file)*
- [X] All new/updated code is covered with tests

## Security

- [X] Security impact of change has been considered
- [ ] Code follows company security practices and guidelines *(Is this referencing Redocly's security practices?)*


Patch:
diff --git a/.changeset/hot-bulldogs-grow.md b/.changeset/hot-bulldogs-grow.md
new file mode 100644
index 00000000..381257e7
--- /dev/null
+++ b/.changeset/hot-bulldogs-grow.md
@@ -0,0 +1,6 @@
+---
+'@redocly/openapi-core': patch
+'@redocly/cli': patch
+---
+
+Fixed an issue when the --remove-unused-components option was removing used components that were referenced as a child object.
diff --git a/packages/core/src/rules/oas2/remove-unused-components.ts b/packages/core/src/rules/oas2/remove-unused-components.ts
index 68b4aab5..d5697241 100644
--- a/packages/core/src/rules/oas2/remove-unused-components.ts
+++ b/packages/core/src/rules/oas2/remove-unused-components.ts
@@ -27,7 +27,12 @@ export const RemoveUnusedComponents: Oas2Rule = () => {
         if (['Schema', 'Parameter', 'Response', 'SecurityScheme'].includes(type.name)) {
           const resolvedRef = resolve(ref);
           if (!resolvedRef.location) return;
-          components.set(resolvedRef.location.absolutePointer, {
+
+          const [fileLocation, localPointer] = resolvedRef.location.absolutePointer.split('#', 2);
+          const componentLevelLocalPointer = localPointer.split('/').slice(0, 3).join('/');
+          const pointer = `${fileLocation}#${componentLevelLocalPointer}`;
+
+          components.set(pointer, {
             used: true,
             name: key.toString(),
           });
diff --git a/packages/core/src/rules/oas3/remove-unused-components.ts b/packages/core/src/rules/oas3/remove-unused-components.ts
index a3d421ef..eaddda38 100644
--- a/packages/core/src/rules/oas3/remove-unused-components.ts
+++ b/packages/core/src/rules/oas3/remove-unused-components.ts
@@ -31,7 +31,12 @@ export const RemoveUnusedComponents: Oas3Rule = () => {
         ) {
           const resolvedRef = resolve(ref);
           if (!resolvedRef.location) return;
-          components.set(resolvedRef.location.absolutePointer, {
+
+          const [fileLocation, localPointer] = resolvedRef.location.absolutePointer.split('#', 2);
+          const componentLevelLocalPointer = localPointer.split('/').slice(0, 4).join('/');
+          const pointer = `${fileLocation}#${componentLevelLocalPointer}`;
+
+          components.set(pointer, {
             used: true,
             name: key.toString(),
           });


Test patch:
diff --git a/packages/core/src/rules/oas2/__tests__/remove-unused-components.test.ts b/packages/core/src/rules/oas2/__tests__/remove-unused-components.test.ts
new file mode 100644
index 00000000..9b0febbf
--- /dev/null
+++ b/packages/core/src/rules/oas2/__tests__/remove-unused-components.test.ts
@@ -0,0 +1,155 @@
+import { outdent } from 'outdent';
+import { parseYamlToDocument, replaceSourceWithRef, makeConfig } from '../../../../__tests__/utils';
+import { bundleDocument } from '../../../bundle';
+import { BaseResolver } from '../../../resolve';
+
+describe('oas2 remove-unused-components', () => {
+  it('should remove unused components', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+        swagger: '2.0'
+        paths:
+          /pets:
+            get:
+              produces:
+                - application/json
+              parameters: []
+              responses:
+                '200':
+                  schema:
+                    $ref: '#/definitions/Used'
+              operationId: listPets
+              summary: List all pets
+        definitions:
+          Unused:
+            enum:
+              - 1
+              - 2
+            type: integer
+          Used:
+            properties:
+              link:
+                type: string
+            type: object
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await bundleDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({}),
+      removeUnusedComponents: true,
+    });
+
+    expect(results.bundle.parsed).toEqual({
+      swagger: '2.0',
+      definitions: {
+        Used: {
+          properties: {
+            link: { type: 'string' },
+          },
+          type: 'object',
+        },
+      },
+      paths: {
+        '/pets': {
+          get: {
+            produces: ['application/json'],
+            parameters: [],
+            summary: 'List all pets',
+            operationId: 'listPets',
+            responses: {
+              '200': {
+                schema: {
+                  $ref: '#/definitions/Used',
+                },
+              },
+            },
+          },
+        },
+      },
+    });
+  });
+
+  it('should not remove components used child reference', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+        swagger: '2.0'
+        paths:
+          /pets:
+            get:
+              produces:
+                - application/json
+              parameters: []
+              responses:
+                '200':
+                  schema:
+                    $ref: '#/definitions/Used'
+              operationId: listPets
+              summary: List all pets
+        definitions:
+          InnerUsed:
+            properties:
+              link:
+                type: string
+            type: object
+          Unused:
+            enum:
+              - 1
+              - 2
+            type: integer
+          Used:
+            properties:
+              link:
+                $ref: '#/definitions/InnerUsed/properties/link'
+            type: object
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await bundleDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({}),
+      removeUnusedComponents: true,
+    });
+
+    expect(results.bundle.parsed).toEqual({
+      swagger: '2.0',
+      definitions: {
+        InnerUsed: {
+          properties: {
+            link: {
+              type: 'string',
+            },
+          },
+          type: 'object',
+        },
+        Used: {
+          properties: {
+            link: { $ref: '#/definitions/InnerUsed/properties/link' },
+          },
+          type: 'object',
+        },
+      },
+      paths: {
+        '/pets': {
+          get: {
+            produces: ['application/json'],
+            parameters: [],
+            summary: 'List all pets',
+            operationId: 'listPets',
+            responses: {
+              '200': {
+                schema: {
+                  $ref: '#/definitions/Used',
+                },
+              },
+            },
+          },
+        },
+      },
+    });
+  });
+});
diff --git a/packages/core/src/rules/oas3/__tests__/remove-unused-components.test.ts b/packages/core/src/rules/oas3/__tests__/remove-unused-components.test.ts
new file mode 100644
index 00000000..a708be88
--- /dev/null
+++ b/packages/core/src/rules/oas3/__tests__/remove-unused-components.test.ts
@@ -0,0 +1,171 @@
+import { outdent } from 'outdent';
+import { parseYamlToDocument, makeConfig } from '../../../../__tests__/utils';
+import { bundleDocument } from '../../../bundle';
+import { BaseResolver } from '../../../resolve';
+
+describe('oas3 remove-unused-components', () => {
+  it('should remove unused components', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+        openapi: "3.0.0"
+        paths:
+          /pets:
+            get:
+              summary: List all pets
+              operationId: listPets
+              parameters:
+                - $ref: '#/components/parameters/used'
+        components:
+          parameters:
+            used:
+              name: used
+            unused:
+              name: unused
+          responses:
+            unused: {}
+          examples:
+            unused: {}
+          requestBodies:
+            unused: {}
+          headers:
+            unused: {}
+          schemas:
+            Unused:
+              type: integer
+              enum:
+                - 1
+                - 2
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await bundleDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({}),
+      removeUnusedComponents: true,
+    });
+
+    expect(results.bundle.parsed).toEqual({
+      openapi: '3.0.0',
+      paths: {
+        '/pets': {
+          get: {
+            summary: 'List all pets',
+            operationId: 'listPets',
+            parameters: [
+              {
+                $ref: '#/components/parameters/used',
+              },
+            ],
+          },
+        },
+      },
+      components: {
+        parameters: {
+          used: {
+            name: 'used',
+          },
+        },
+      },
+    });
+  });
+
+  it('should not remove components used child reference', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+        openapi: "3.0.0"
+        paths:
+          /pets:
+            get:
+              summary: List all pets
+              operationId: listPets
+              responses:
+                '200':
+                  content:
+                    application/json:
+                      schema:
+                        $ref: '#/components/schemas/Used'
+        components:
+          parameters:
+            unused:
+              name: unused
+          responses:
+            unused: {}
+          examples:
+            unused: {}
+          requestBodies:
+            unused: {}
+          headers:
+            unused: {}
+          schemas:
+            InnerUsed:
+              type: object
+              properties:
+                link:
+                  type: string
+            Used:
+              type: object
+              properties:
+                link:
+                  $ref: '#/components/schemas/InnerUsed/properties/link'
+            Unused:
+              type: integer
+              enum:
+                - 1
+                - 2
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await bundleDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({}),
+      removeUnusedComponents: true,
+    });
+
+    expect(results.bundle.parsed).toEqual({
+      openapi: '3.0.0',
+      paths: {
+        '/pets': {
+          get: {
+            summary: 'List all pets',
+            operationId: 'listPets',
+            responses: {
+              '200': {
+                content: {
+                  'application/json': {
+                    schema: {
+                      $ref: '#/components/schemas/Used',
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+      },
+      components: {
+        schemas: {
+          InnerUsed: {
+            type: 'object',
+            properties: {
+              link: {
+                type: 'string',
+              },
+            },
+          },
+          Used: {
+            type: 'object',
+            properties: {
+              link: {
+                $ref: '#/components/schemas/InnerUsed/properties/link',
+              },
+            },
+          },
+        },
+      },
+    });
+  });
+});