Repository: IBM/openapi-validator
Pull request #741: Fix minor problems with utility collections and add tests
Author: login: hudlow
Labels: name: released
Created: 2025-04-14T14:57:53Z | Merged: 2025-04-16T19:47:58Z

## PR summary
- Rename `responseSchemas` to `responseBodySchemas` (matching `requestBodySchemas`) since there are also response _header_ schemas considered separately.
- Add response header **content** schemas to consideration in the `schemas` collection (they were already considered in the `unresolvedSchemas` collection) and rework the schema collection slightly for clarity.
- Add tests for all collections!

## PR Checklist

### General checklist
Please make sure that your PR fulfills the following requirements:
- [x] The commit message follows the [Angular Commit Message Guidelines](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines).
- [x] Tests for the changes have been added (for bug fixes / features)
- [x] Docs have been added / updated (for bug fixes / features)
- ~[ ] Dependencies have been updated as needed~
- ~[ ] `.secrets.baseline` has been updated as needed~
- [x] `npm run generate-utilities-docs` has been run if any files in `packages/utilities/src` have been updated

#### ~Checklist for adding a new validation rule:~
- ~[ ] Added new validation rule definition (packages/ruleset/src/rules/*.js, index.js)~
- ~[ ] If necessary, added new validation rule implementation~(packages/ruleset/src/functions/*.js, updated index.js)~
- ~[ ] Added new rule to default configuration (packages/ruleset/src/ibm-oas.js)~
- ~[ ] Added tests for new rule (packages/ruleset/test/*.test.js)~
- ~[ ] Added docs for new rule (docs/ibm-cloud-rules.md)~
- ~[ ] Added scoring rubric entry for new rule (packages/validator/src/scoring-tool/rubric.js)~



Diff:
diff --git a/packages/utilities/src/collections/index.js b/packages/utilities/src/collections/index.js
index a69a72bab..f23e005a1 100644
--- a/packages/utilities/src/collections/index.js
+++ b/packages/utilities/src/collections/index.js
@@ -58,9 +58,11 @@ const collections = {
     `$.paths[*][parameters][*].schema`,
     `$.paths[*][parameters][*].content[*].schema`,
     `${operations[0]}[parameters][*].schema`,
-    `${operations[0]}[parameters,responses][*].content[*].schema`,
+    `${operations[0]}[parameters][*].content[*].schema`,
     `${operations[0]}.responses[*].headers[*].schema`,
+    `${operations[0]}.responses[*].headers[*].content[*].schema`,
     ...requestBodySchemas,
+    ...responseSchemas,
   ],
   /**
    * locations of security scheme definitions
diff --git a/packages/utilities/test/collections.test.js b/packages/utilities/test/collections.test.js
new file mode 100644
index 000000000..9aab742d0
--- /dev/null
+++ b/packages/utilities/test/collections.test.js
@@ -0,0 +1,1267 @@
+/**
+ * Copyright 2025 IBM Corporation.
+ * SPDX-License-Identifier: Apache2.0
+ */
+
+const { collections } = require('../src');
+const testRulePaths = require('./utils/test-rule-paths');
+
+describe('Collections', () => {
+  describe('operations', () => {
+    const methods = [
+      'get',
+      'put',
+      'post',
+      'delete',
+      'options',
+      'head',
+      'patch',
+      'trace',
+    ];
+    for (const method of methods) {
+      it(`should find ${method} operations`, async () => {
+        const doc = { paths: { '/': { [method]: {} } } };
+        const visitedPaths = await testRulePaths(collections.operations, doc);
+
+        expect(visitedPaths).toEqual([['paths', '/', method]]);
+      });
+    }
+
+    it(`should not find non-operations in a path item`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            summary: 'foo',
+            description: 'bar',
+            servers: [{ url: 'https://api.example.com/v1' }],
+            parameters: [{ name: 'baz', in: 'query' }],
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(collections.operations, doc);
+
+      expect(visitedPaths.length).toEqual(0);
+    });
+  });
+
+  describe('parameters', () => {
+    it(`should find path parameters`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            parameters: [{}],
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(collections.parameters, doc);
+
+      expect(visitedPaths.sort()).toEqual([['paths', '/', 'parameters', 0]]);
+    });
+    it(`should find operation parameters`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              parameters: [{}],
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(collections.parameters, doc);
+
+      expect(visitedPaths.sort()).toEqual([
+        ['paths', '/', 'post', 'parameters', 0],
+      ]);
+    });
+    it(`should not find unreferenced parameters`, async () => {
+      const doc = {
+        components: {
+          parameters: [{}],
+        },
+      };
+
+      const visitedPaths = await testRulePaths(collections.parameters, doc);
+
+      expect(visitedPaths.length).toEqual(0);
+    });
+  });
+
+  describe('patchOperations', () => {
+    it(`should find patch operations`, async () => {
+      const doc = { paths: { '/': { patch: {} } } };
+
+      const visitedPaths = await testRulePaths(
+        collections.patchOperations,
+        doc
+      );
+
+      expect(visitedPaths).toEqual([['paths', '/', 'patch']]);
+    });
+    it(`should not find non-patch operations`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            get: {},
+            put: {},
+            post: {},
+            delete: {},
+            options: {},
+            head: {},
+            trace: {},
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.patchOperations,
+        doc
+      );
+
+      expect(visitedPaths.length).toEqual(0);
+    });
+  });
+
+  describe('paths', () => {
+    it(`should find paths`, async () => {
+      const doc = {
+        paths: {
+          '/1': {},
+          '/2': {},
+          '/3': {},
+        },
+      };
+
+      const visitedPaths = await testRulePaths(collections.paths, doc);
+
+      expect(visitedPaths).toEqual([
+        ['paths', '/1'],
+        ['paths', '/2'],
+        ['paths', '/3'],
+      ]);
+    });
+  });
+
+  describe('requestBodySchemas', () => {
+    it(`should find request body schemas`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              requestBody: {
+                content: {
+                  'application/json': {
+                    schema: {},
+                  },
+                  'application/xml': {
+                    schema: {},
+                  },
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.requestBodySchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'paths',
+            '/',
+            'post',
+            'requestBody',
+            'content',
+            'application/json',
+            'schema',
+          ],
+          [
+            'paths',
+            '/',
+            'post',
+            'requestBody',
+            'content',
+            'application/xml',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+  });
+
+  describe('responseSchemas', () => {
+    it(`should find response body schemas`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              responses: {
+                200: {
+                  content: {
+                    'application/json': {
+                      schema: {},
+                    },
+                    'application/xml': {
+                      schema: {},
+                    },
+                  },
+                },
+                '4xx': {
+                  content: {
+                    'application/json': {
+                      schema: {},
+                    },
+                    'application/xml': {
+                      schema: {},
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.responseSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'paths',
+            '/',
+            'post',
+            'responses',
+            '200',
+            'content',
+            'application/json',
+            'schema',
+          ],
+          [
+            'paths',
+            '/',
+            'post',
+            'responses',
+            '200',
+            'content',
+            'application/xml',
+            'schema',
+          ],
+          [
+            'paths',
+            '/',
+            'post',
+            'responses',
+            '4xx',
+            'content',
+            'application/json',
+            'schema',
+          ],
+          [
+            'paths',
+            '/',
+            'post',
+            'responses',
+            '4xx',
+            'content',
+            'application/xml',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+  });
+
+  describe('schemas', () => {
+    it(`should find operation parameter schemas`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              parameters: [{ schema: {} }],
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(collections.schemas, doc);
+
+      expect(visitedPaths.sort()).toEqual(
+        [['paths', '/', 'post', 'parameters', 0, 'schema']].sort()
+      );
+    });
+    it(`should find operation parameter content schemas`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              parameters: [{ content: { 'application/json': { schema: {} } } }],
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(collections.schemas, doc);
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'paths',
+            '/',
+            'post',
+            'parameters',
+            0,
+            'content',
+            'application/json',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+    it(`should find path parameter schemas`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            parameters: [{ schema: {} }],
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(collections.schemas, doc);
+
+      expect(visitedPaths.sort()).toEqual(
+        [['paths', '/', 'parameters', 0, 'schema']].sort()
+      );
+    });
+    it(`should find path parameter content schemas`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            parameters: [{ content: { 'application/json': { schema: {} } } }],
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(collections.schemas, doc);
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'paths',
+            '/',
+            'parameters',
+            0,
+            'content',
+            'application/json',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+    it(`should find response header schemas`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              responses: {
+                200: {
+                  headers: {
+                    Foo: {
+                      schema: {},
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(collections.schemas, doc);
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'paths',
+            '/',
+            'post',
+            'responses',
+            '200',
+            'headers',
+            'Foo',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+    it(`should find response header content schemas`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              responses: {
+                200: {
+                  headers: {
+                    Foo: {
+                      content: {
+                        'application/json': {
+                          schema: {},
+                        },
+                      },
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(collections.schemas, doc);
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'paths',
+            '/',
+            'post',
+            'responses',
+            '200',
+            'headers',
+            'Foo',
+            'content',
+            'application/json',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+    it(`should find request body schemas`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              requestBody: {
+                content: {
+                  'application/json': {
+                    schema: {},
+                  },
+                  'application/xml': {
+                    schema: {},
+                  },
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(collections.schemas, doc);
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'paths',
+            '/',
+            'post',
+            'requestBody',
+            'content',
+            'application/json',
+            'schema',
+          ],
+          [
+            'paths',
+            '/',
+            'post',
+            'requestBody',
+            'content',
+            'application/xml',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+    it(`should find response body schemas`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              responses: {
+                200: {
+                  content: {
+                    'application/json': {
+                      schema: {},
+                    },
+                    'application/xml': {
+                      schema: {},
+                    },
+                  },
+                },
+                '4xx': {
+                  content: {
+                    'application/json': {
+                      schema: {},
+                    },
+                    'application/xml': {
+                      schema: {},
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.responseSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'paths',
+            '/',
+            'post',
+            'responses',
+            '200',
+            'content',
+            'application/json',
+            'schema',
+          ],
+          [
+            'paths',
+            '/',
+            'post',
+            'responses',
+            '200',
+            'content',
+            'application/xml',
+            'schema',
+          ],
+          [
+            'paths',
+            '/',
+            'post',
+            'responses',
+            '4xx',
+            'content',
+            'application/json',
+            'schema',
+          ],
+          [
+            'paths',
+            '/',
+            'post',
+            'responses',
+            '4xx',
+            'content',
+            'application/xml',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+  });
+
+  describe('securitySchemes', () => {
+    it(`should find security schemes`, async () => {
+      const doc = {
+        components: {
+          securitySchemes: {
+            foo: {},
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.securitySchemes,
+        doc
+      );
+
+      expect(visitedPaths).toEqual([['components', 'securitySchemes', 'foo']]);
+    });
+  });
+
+  describe('unresolvedRequestBodySchemas', () => {
+    it(`should find request body schemas in operations`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              requestBody: {
+                content: {
+                  'application/json': {
+                    schema: {},
+                  },
+                  'application/xml': {
+                    schema: {},
+                  },
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedRequestBodySchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'paths',
+            '/',
+            'post',
+            'requestBody',
+            'content',
+            'application/json',
+            'schema',
+          ],
+          [
+            'paths',
+            '/',
+            'post',
+            'requestBody',
+            'content',
+            'application/xml',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+    it(`should find request body schemas in components`, async () => {
+      const doc = {
+        components: {
+          requestBodies: {
+            foo: {
+              content: {
+                'application/json': {
+                  schema: {},
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedRequestBodySchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'components',
+            'requestBodies',
+            'foo',
+            'content',
+            'application/json',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+  });
+
+  describe('unresolvedResponseSchemas', () => {
+    it(`should find response body schemas`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              responses: {
+                200: {
+                  content: {
+                    'application/json': {
+                      schema: {},
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedResponseSchemas,
+        doc
+      );
+
+      expect(visitedPaths).toEqual([
+        [
+          'paths',
+          '/',
+          'post',
+          'responses',
+          '200',
+          'content',
+          'application/json',
+          'schema',
+        ],
+      ]);
+    });
+    it(`should find response body schemas in components`, async () => {
+      const doc = {
+        components: {
+          responses: {
+            foo: {
+              content: {
+                'application/json': {
+                  schema: {},
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedResponseSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'components',
+            'responses',
+            'foo',
+            'content',
+            'application/json',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+  });
+
+  describe('unresolvedSchemas', () => {
+    it(`should find parameter schemas in operations`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              parameters: [{ schema: {} }],
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [['paths', '/', 'post', 'parameters', 0, 'schema']].sort()
+      );
+    });
+    it(`should find parameter content schemas in operations`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              parameters: [{ content: { 'application/json': { schema: {} } } }],
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'paths',
+            '/',
+            'post',
+            'parameters',
+            0,
+            'content',
+            'application/json',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+    it(`should find parameter schemas in paths`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            parameters: [{ schema: {} }],
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [['paths', '/', 'parameters', 0, 'schema']].sort()
+      );
+    });
+    it(`should find parameter content schemas in paths`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            parameters: [{ content: { 'application/json': { schema: {} } } }],
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'paths',
+            '/',
+            'parameters',
+            0,
+            'content',
+            'application/json',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+    it(`should find parameter schemas in components`, async () => {
+      const doc = {
+        components: {
+          parameters: {
+            foo: { schema: {} },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [['components', 'parameters', 'foo', 'schema']].sort()
+      );
+    });
+    it(`should find parameter content schemas in components`, async () => {
+      const doc = {
+        components: {
+          parameters: {
+            foo: { content: { 'application/json': { schema: {} } } },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'components',
+            'parameters',
+            'foo',
+            'content',
+            'application/json',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+    it(`should find response header schemas in operations`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              responses: {
+                200: {
+                  headers: {
+                    Foo: {
+                      schema: {},
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'paths',
+            '/',
+            'post',
+            'responses',
+            '200',
+            'headers',
+            'Foo',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+    it(`should find response header content schemas in operations`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              responses: {
+                200: {
+                  headers: {
+                    Foo: {
+                      content: {
+                        'application/json': {
+                          schema: {},
+                        },
+                      },
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'paths',
+            '/',
+            'post',
+            'responses',
+            '200',
+            'headers',
+            'Foo',
+            'content',
+            'application/json',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+    it(`should find header schemas in components`, async () => {
+      const doc = {
+        components: {
+          headers: {
+            Foo: {
+              schema: {},
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [['components', 'headers', 'Foo', 'schema']].sort()
+      );
+    });
+    it(`should find header content schemas in components`, async () => {
+      const doc = {
+        components: {
+          headers: {
+            Foo: {
+              content: {
+                'application/json': {
+                  schema: {},
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'components',
+            'headers',
+            'Foo',
+            'content',
+            'application/json',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+    it(`should find response header schemas in components`, async () => {
+      const doc = {
+        components: {
+          responses: {
+            foo: {
+              headers: {
+                Foo: {
+                  schema: {},
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [['components', 'responses', 'foo', 'headers', 'Foo', 'schema']].sort()
+      );
+    });
+    it(`should find response header content schemas in components`, async () => {
+      const doc = {
+        components: {
+          responses: {
+            foo: {
+              headers: {
+                Foo: {
+                  content: {
+                    'application/json': {
+                      schema: {},
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'components',
+            'responses',
+            'foo',
+            'headers',
+            'Foo',
+            'content',
+            'application/json',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+    it(`should find request body schemas in operations`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              requestBody: {
+                content: {
+                  'application/json': {
+                    schema: {},
+                  },
+                  'application/xml': {
+                    schema: {},
+                  },
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'paths',
+            '/',
+            'post',
+            'requestBody',
+            'content',
+            'application/json',
+            'schema',
+          ],
+          [
+            'paths',
+            '/',
+            'post',
+            'requestBody',
+            'content',
+            'application/xml',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+    it(`should find request body schemas in components`, async () => {
+      const doc = {
+        components: {
+          requestBodies: {
+            foo: {
+              content: {
+                'application/json': {
+                  schema: {},
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'components',
+            'requestBodies',
+            'foo',
+            'content',
+            'application/json',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+    it(`should find response body schemas`, async () => {
+      const doc = {
+        paths: {
+          '/': {
+            post: {
+              responses: {
+                200: {
+                  content: {
+                    'application/json': {
+                      schema: {},
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths).toEqual([
+        [
+          'paths',
+          '/',
+          'post',
+          'responses',
+          '200',
+          'content',
+          'application/json',
+          'schema',
+        ],
+      ]);
+    });
+    it(`should find response body schemas in components`, async () => {
+      const doc = {
+        components: {
+          responses: {
+            foo: {
+              content: {
+                'application/json': {
+                  schema: {},
+                },
+              },
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [
+          [
+            'components',
+            'responses',
+            'foo',
+            'content',
+            'application/json',
+            'schema',
+          ],
+        ].sort()
+      );
+    });
+    it(`should find schemas in components`, async () => {
+      const doc = {
+        components: {
+          schemas: {
+            Foo: {
+              schema: {},
+            },
+          },
+        },
+      };
+
+      const visitedPaths = await testRulePaths(
+        collections.unresolvedSchemas,
+        doc
+      );
+
+      expect(visitedPaths.sort()).toEqual(
+        [['components', 'schemas', 'Foo']].sort()
+      );
+    });
+  });
+});
diff --git a/packages/utilities/test/utils/test-rule-paths.js b/packages/utilities/test/utils/test-rule-paths.js
new file mode 100644
index 000000000..8e07383c8
--- /dev/null
+++ b/packages/utilities/test/utils/test-rule-paths.js
@@ -0,0 +1,23 @@
+/**
+ * Copyright 2025 IBM Corporation.
+ * SPDX-License-Identifier: Apache2.0
+ */
+
+const testRule = require('./test-rule');
+
+module.exports = async (given, doc) => {
+  const visitedPaths = [];
+  const rule = {
+    given,
+    then: {
+      function: (input, options, context) => {
+        visitedPaths.push(context.path);
+        return [];
+      },
+    },
+  };
+
+  await testRule(rule, doc);
+
+  return visitedPaths;
+};
diff --git a/packages/utilities/test/utils/test-rule.js b/packages/utilities/test/utils/test-rule.js
index b6958ba2f..8b32867bb 100644
--- a/packages/utilities/test/utils/test-rule.js
+++ b/packages/utilities/test/utils/test-rule.js
@@ -6,20 +6,14 @@
 // NOTE: Duplicated from ruleset package. Need to revisit
 const { Spectral } = require('@stoplight/spectral-core');
 
-module.exports = async (ruleName, rule, doc) => {
+module.exports = async (rule, doc) => {
   const spectral = new Spectral();
 
   spectral.setRuleset({
     rules: {
-      [ruleName]: rule,
+      'mock-rule': rule,
     },
   });
 
-  try {
-    const results = await spectral.run(doc);
-    return results;
-  } catch (err) {
-    console.error(err);
-    throw err;
-  }
+  return await spectral.run(doc);
 };
diff --git a/packages/utilities/test/validate-subschemas.test.js b/packages/utilities/test/validate-subschemas.test.js
index 47f145e18..42d9845c7 100644
--- a/packages/utilities/test/validate-subschemas.test.js
+++ b/packages/utilities/test/validate-subschemas.test.js
@@ -31,7 +31,7 @@ describe('Utility: validateSubschemas', () => {
   };
 
   it('should find all subschemas', async () => {
-    await testRule('rule-name', ruleForTesting, allSchemasDocument);
+    await testRule(ruleForTesting, allSchemasDocument);
 
     expect(visitedPaths.length).toBe(24);
     expect(visitedLogicalPaths.length).toBe(24);


Reviews:
user: login: dpopp07
body: This looks great! Thanks for adding it. I left a couple comments but nothing that necessarily should hold up merging
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T16:02:13Z
user: login: hudlow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T03:24:39Z
user: login: hudlow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T03:32:53Z
user: login: dpopp07
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T13:56:03Z
user: login: dpopp07
body: Looks great! 👍 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T13:57:17Z

Review comments:
diff_hunk: @@ -8,15 +8,17 @@ const operations = [`$.paths[*][get,put,post,delete,options,head,patch,trace]`];
 /* @private */
 const requestBodySchemas = [`${operations[0]}[requestBody].content[*].schema`];
 /* @private */
-const responseSchemas = [`${operations[0]}[responses][*].content[*].schema`];
+const responseBodySchemas = [
path: packages/utilities/src/collections/index.js
user: login: dpopp07
body: This would technically be a breaking change, right, aside from the fact that it's marked as `private`? It doesn't look like it's marked as private in the generated docs. Also, would we not expect a project like the extensions ruleset to not use theses?

To be clear, I'm perfectly happy to merge this as-is but I want to clarify how we're approaching the contract for the utilities package.
created_at: 2025-04-14T15:58:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,20 +6,14 @@
 // NOTE: Duplicated from ruleset package. Need to revisit
path: packages/utilities/test/utils/test-rule.js
user: login: dpopp07
body: Probably for the best that these test helpers are diverging - maybe we should remove this comment?
created_at: 2025-04-14T16:01:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,20 +6,14 @@
 // NOTE: Duplicated from ruleset package. Need to revisit
path: packages/utilities/test/utils/test-rule.js
user: login: hudlow
body: Actually, I think the changes I made here should also be made to the other one — I'm not sure I see the point of passing the rule name around. So I'm inclined to leave this as a reminder that maybe we do want to re-converge.
created_at: 2025-04-16T03:24:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -8,15 +8,17 @@ const operations = [`$.paths[*][get,put,post,delete,options,head,patch,trace]`];
 /* @private */
 const requestBodySchemas = [`${operations[0]}[requestBody].content[*].schema`];
 /* @private */
-const responseSchemas = [`${operations[0]}[responses][*].content[*].schema`];
+const responseBodySchemas = [
path: packages/utilities/src/collections/index.js
user: login: hudlow
body: This line wasn't a breaking change, but the change to the exported names absolutely were. For some reason I thought the utilities package was still 0.x — I have reverted the name change.
created_at: 2025-04-16T03:32:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -8,15 +8,17 @@ const operations = [`$.paths[*][get,put,post,delete,options,head,patch,trace]`];
 /* @private */
 const requestBodySchemas = [`${operations[0]}[requestBody].content[*].schema`];
 /* @private */
-const responseSchemas = [`${operations[0]}[responses][*].content[*].schema`];
+const responseBodySchemas = [
path: packages/utilities/src/collections/index.js
user: login: dpopp07
body: > the change to the exported names absolutely were

That's what I meant, I picked an arbitrary spot to leave the comment. But sounds good!
created_at: 2025-04-16T13:56:03Z
author_association: CONTRIBUTOR

PR comments:
user: login: ibm-devx-sdk
created_at: 2025-04-16T19:53:10Z
body: :tada: This PR is included in version 1.8.1 :tada:

The release is available on [npm package (@latest dist-tag)](https://www.npmjs.com/package/@ibm-cloud/openapi-ruleset-utilities/v/1.8.1)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: NONE
user: login: ibm-devx-sdk
created_at: 2025-04-16T19:53:25Z
body: :tada: This PR is included in version 1.30.1 :tada:

The release is available on [npm package (@latest dist-tag)](https://www.npmjs.com/package/@ibm-cloud/openapi-ruleset/v/1.30.1)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: NONE
user: login: ibm-devx-sdk
created_at: 2025-04-16T19:54:15Z
body: :tada: This PR is included in version 1.34.1 :tada:

The release is available on [npm package (@latest dist-tag)](https://www.npmjs.com/package/ibm-openapi-validator/v/1.34.1)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: NONE