Skip to content
Open
6 changes: 6 additions & 0 deletions .changeset/vast-kids-add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@redocly/cli': minor
'@redocly/openapi-core': minor
---

Added a `strategy` option to the `component-name-unique` rule, matching the `--component-names-strategy` option of the `bundle` command.
3 changes: 3 additions & 0 deletions docs/@v2/commands/bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,6 @@ All other characters, including non-ASCII letters such as `é` or `я`, are repl
Schemas without `title` can't be named using the `--component-names-strategy=title` strategy.
The bundling process reports an error for such schemas.
{% /admonition %}

To catch name collisions before bundling, set the matching `strategy` option on the
[`component-name-unique`](../rules/oas/component-name-unique.md) rule.
18 changes: 18 additions & 0 deletions docs/@v2/rules/oas/component-name-unique.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ This clearly is not optimal. Having unique component names prevents these proble
| 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. |
| strategy | string | Possible values: `basename`, `title`. Default: `basename`. |

An example configuration:

Expand All @@ -48,8 +49,25 @@ rules:
parameters: off
responses: warn
requestBodies: warn
strategy: basename
```

### Component names strategy

The rule predicts the component names that `bundle` produces, so `strategy` must match the
[`--component-names-strategy`](../../commands/bundle.md#configure-the-component-names-strategy) option you bundle with.

With the default `basename`, a schema pulled in from another file is named after the `$ref` fragment or the file name.
Two files both called `Order.yaml` therefore collide, and the rule reports them.

With `title`, the same schemas are named after their `title` field instead.
Two files called `Order.yaml` with the titles `Order model` and `Order request` become `OrderModel` and `OrderRequest`, so the rule no longer reports them.
Two schemas in differently named files that share a title do collide, and the rule reports those instead.

The `title` strategy applies only to schemas that are referenced from another file, because those are the only ones `bundle` renames.
Schemas defined directly under the root description's `components/schemas` keep their own key.
A referenced schema without a `title` falls back to the file name — `bundle` reports the missing title itself.

## Examples

Given this configuration:
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/bundle/bundle-visitor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type RuleSeverity } from '../config/types.js';
import { COMPONENT_NAME_CHARS, type SpecMajorVersion } from '../oas-types.js';
import { type SpecMajorVersion } from '../oas-types.js';
import {
isAbsoluteUrl,
replaceRef,
Expand All @@ -14,11 +14,11 @@ import {
import { type ResolvedRefMap, type Document } from '../resolve.js';
import { reportUnresolvedRef } from '../rules/common/no-unresolved-refs.js';
import { type OasRef, type Oas3Discriminator, type Oas3Example } from '../typings/openapi.js';
import { componentNameFromTitle } from '../utils/component-name-from-title.js';
import { dequal } from '../utils/dequal.js';
import { isPlainObject } from '../utils/is-plain-object.js';
import { isString } from '../utils/is-string.js';
import { makeRefId } from '../utils/make-ref-id.js';
import { toPascalCase } from '../utils/to-pascal-case.js';
import { type Oas3Visitor, type Oas2Visitor } from '../visitors.js';
import { type UserContext, type ResolveResult, type NonUndefined, type Problem } from '../walk.js';
import { type ComponentNamesStrategy } from './bundle-document.js';
Expand Down Expand Up @@ -320,14 +320,14 @@ export function makeBundleVisitor({
return dequal(node, target.node);
}

function componentNameFromTitle(
function resolveComponentNameFromTitle(
target: ComponentTarget,
componentsGroup: ComponentsGroup,
ctx: UserContext
): { key: string; problem?: Problem } {
const { node } = target;
const title = isPlainObject(node) && isString(node.title) ? node.title.trim() : '';
const key = toPascalCase(title).replace(new RegExp(`[^${COMPONENT_NAME_CHARS}]`, 'g'), '-');
const key = componentNameFromTitle(title);
const titleLocation = target.location.child('title');

if (title === '') {
Expand Down Expand Up @@ -379,7 +379,7 @@ export function makeBundleVisitor({
const componentsGroup = components[componentType];

if (componentNamesStrategy === 'title' && componentType === schemaComponentType) {
const { key, problem } = componentNameFromTitle(target, componentsGroup, ctx);
const { key, problem } = resolveComponentNameFromTitle(target, componentsGroup, ctx);
if (!problem) {
firstSchemaLocationByName.set(key, target.location.child('title'));
return key;
Expand Down
186 changes: 186 additions & 0 deletions packages/core/src/rules/oas3/__tests__/component-name-unique.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -986,4 +986,190 @@ describe('Oas3 component-name-unique', () => {
`);
});
});

describe('strategy: title', () => {
it('should not report on same filenames with different titles', async () => {
const document = parseYamlToDocument(
outdent`
openapi: 3.0.0
components:
schemas:
Test:
type: object
properties:
model:
$ref: '/a/Order.yaml'
request:
$ref: '/b/Order.yaml'
`,
'/foobar.yaml'
);
const additionalDocuments = [
{
absoluteRef: '/a/Order.yaml',
body: outdent`
title: Order model
type: object
`,
},
{
absoluteRef: '/b/Order.yaml',
body: outdent`
title: Order request
type: object
`,
},
];

const results = await lintDocumentForTest(
{ 'component-name-unique': { severity: 'error', strategy: 'title' } },
document,
additionalDocuments
);

expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`);
});

it('should report on different filenames with the same title', async () => {
const document = parseYamlToDocument(
outdent`
openapi: 3.0.0
components:
schemas:
Test:
type: object
properties:
user:
$ref: '/a/User.yaml'
account:
$ref: '/b/Account.yaml'
`,
'/foobar.yaml'
);
const additionalDocuments = [
{
absoluteRef: '/a/User.yaml',
body: outdent`
title: User account
type: object
`,
},
{
absoluteRef: '/b/Account.yaml',
body: outdent`
title: User account
type: object
`,
},
];

const results = await lintDocumentForTest(
{ 'component-name-unique': { severity: 'error', strategy: 'title' } },
document,
additionalDocuments
);

expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
[
{
"location": [
{
"pointer": "#/",
"reportOnKey": false,
"source": "/a/User.yaml",
},
],
"message": "Component 'schemas/UserAccount' is not unique. It is also defined at:
- /b/Account.yaml",
"reference": "https://redocly.com/docs/cli/rules/oas/component-name-unique",
"ruleId": "component-name-unique",
"severity": "error",
"suggest": [],
},
{
"location": [
{
"pointer": "#/",
"reportOnKey": false,
"source": "/b/Account.yaml",
},
],
"message": "Component 'schemas/UserAccount' is not unique. It is also defined at:
- /a/User.yaml",
"reference": "https://redocly.com/docs/cli/rules/oas/component-name-unique",
"ruleId": "component-name-unique",
"severity": "error",
"suggest": [],
},
]
`);
});

it('should fall back to the filename when a schema has no title', async () => {
const document = parseYamlToDocument(
outdent`
openapi: 3.0.0
components:
schemas:
Order:
type: object
Test:
type: object
properties:
order:
$ref: '/a/Order.yaml'
`,
'/foobar.yaml'
);
const additionalDocuments = [
{
absoluteRef: '/a/Order.yaml',
body: outdent`
type: object
`,
},
];

const results = await lintDocumentForTest(
{ 'component-name-unique': { severity: 'error', strategy: 'title' } },
document,
additionalDocuments
);

expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
[
{
"location": [
{
"pointer": "#/components/schemas/Order",
"reportOnKey": false,
"source": "/foobar.yaml",
},
],
"message": "Component 'schemas/Order' is not unique. It is also defined at:
- /a/Order.yaml",
"reference": "https://redocly.com/docs/cli/rules/oas/component-name-unique",
"ruleId": "component-name-unique",
"severity": "error",
"suggest": [],
},
{
"location": [
{
"pointer": "#/",
"reportOnKey": false,
"source": "/a/Order.yaml",
},
],
"message": "Component 'schemas/Order' is not unique. It is also defined at:
- /foobar.yaml#/components/schemas/Order",
"reference": "https://redocly.com/docs/cli/rules/oas/component-name-unique",
"ruleId": "component-name-unique",
"severity": "error",
"suggest": [],
},
]
`);
});
});
});
32 changes: 31 additions & 1 deletion packages/core/src/rules/oas3/component-name-unique.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import type {
Oas3_1Schema,
OasRef,
} from '../../typings/openapi.js';
import { componentNameFromTitle } from '../../utils/component-name-from-title.js';
import { isPlainObject } from '../../utils/is-plain-object.js';
import { isString } from '../../utils/is-string.js';
import { isSupportedExtension } from '../../utils/is-supported-extension.js';
import type { Oas2Rule, Oas3Rule, Oas3Visitor } from '../../visitors.js';
import type { Problem, UserContext } from '../../walk.js';
Expand All @@ -32,6 +35,8 @@ type ComponentsMapValue = { absolutePointers: Set<string>; locations: Location[]

export const ComponentNameUnique: Oas3Rule | Oas2Rule = (options) => {
const components = new Map<string, ComponentsMapValue>();
const useTitleStrategy = options.strategy === 'title';
let rootSourceRef: string;

const typeNames: string[] = [];
if (options.schemas !== 'off') {
Expand All @@ -55,11 +60,19 @@ export const ComponentNameUnique: Oas3Rule | Oas2Rule = (options) => {
const resolvedRef = resolve(ref);
if (!resolvedRef.location) return;

addComponentFromAbsoluteLocation(typeName, resolvedRef.location);
const titleName = getTitleComponentName(typeName, resolvedRef);
if (titleName) {
addFoundComponent(typeName, titleName, resolvedRef.location);

@kanoru3101 kanoru3101 Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The report points at the start of the file, but the line to change is the title. Pass resolvedRef.location.child('title') here, in the same place the bundler reports at bundle-visitor.ts.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reports point at file root

Medium Severity

When the title strategy records a component, it stores resolvedRef.location, so collisions report at the file root. The field that determines the bundled name is title; bundle-visitor.ts reports at location.child('title') for the same reason.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cd72939. Configure here.

} else {
addComponentFromAbsoluteLocation(typeName, resolvedRef.location);
}
}
},
},
Root: {
enter(_: AnyOas3Definition, { location }: UserContext) {
rootSourceRef = location.source.absoluteRef;
},
Comment thread
harshit078 marked this conversation as resolved.
leave(root: AnyOas3Definition, ctx: UserContext) {
components.forEach((value, key, _) => {
if (value.absolutePointers.size > 1) {
Expand Down Expand Up @@ -147,6 +160,23 @@ export const ComponentNameUnique: Oas3Rule | Oas2Rule = (options) => {
const componentName = getComponentNameFromAbsoluteLocation(location.absolutePointer.toString());
addFoundComponent(typeName, componentName, location);
}

function getTitleComponentName(
typeName: string,
resolved: { node: unknown; location: Location }
): string | null {
if (
!useTitleStrategy ||
typeName !== TYPE_NAME_SCHEMA ||
resolved.location.source.absoluteRef === rootSourceRef

@kanoru3101 kanoru3101 Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rule skips the title strategy for every schema in the root file. The bundle command does not skip those schemas. It renames a root schema if an external file refers to it. So two schemas end up wanting the same name and the rule stays quiet.
Example

# openapi.yaml
components:
  schemas:
    Foo:
      title: Bar thing
      type: object
# ...a response in this file refers to ./Other.yaml
# Other.yaml
title: Bar thing
type: object
properties:
  inner:
    $ref: './openapi.yaml#/components/schemas/Foo'

The bundle is still written, but Other.yaml gets Other, not the name from its title. The rule sees no problem here, the bundler does both and should see the same. Please check where the $ref comes from, not only where it points.

) {
return null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root schemas skip title strategy

High Severity

getTitleComponentName returns null for every schema whose target lives in the root file. bundle only skips renaming when both the target and the $ref are in the root; an external file that references a root schema still renames it from title. Collisions between that renamed root schema and another title-named schema go unreported.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cd72939. Configure here.


const { node } = resolved;
const title = isPlainObject(node) && isString(node.title) ? node.title.trim() : '';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates code that already exists; you should consider reusing the existing logic

return title === '' ? null : componentNameFromTitle(title);

@kanoru3101 kanoru3101 Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a schema has no title, the rule uses the file name and says nothing. The bundler doesn't stop.

# openapi.yaml
openapi: 3.0.0
info:
  title: Referenced schema without a title
  version: 1.0.0
paths:
  /carts:
    get:
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                $ref: './Cart.yaml'
# Cart.yaml - no title here
type: object
properties:
  total:
    type: number

Lint says the description is fine, and then there is no bundle at all. The two commands disagree again, and this is the most common way the title strategy breaks, so the rule should report it:

}
};

function getOptionComponentNameForTypeName(typeName: string): string | null {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/utils/component-name-from-title.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { COMPONENT_NAME_CHARS } from '../oas-types.js';
import { toPascalCase } from './to-pascal-case.js';

export function componentNameFromTitle(title: string): string {
return toPascalCase(title).replace(new RegExp(`[^${COMPONENT_NAME_CHARS}]`, 'g'), '-');
}
Loading