From 026b58dc9c24745b575bba9c167202a89efcdfc3 Mon Sep 17 00:00:00 2001 From: Harshit Singh Date: Tue, 4 Aug 2026 17:42:29 +0530 Subject: [PATCH 1/8] feat: add componentNameFromTitle utility function --- packages/core/src/utils/component-name-from-title.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 packages/core/src/utils/component-name-from-title.ts diff --git a/packages/core/src/utils/component-name-from-title.ts b/packages/core/src/utils/component-name-from-title.ts new file mode 100644 index 0000000000..cd5d67cd54 --- /dev/null +++ b/packages/core/src/utils/component-name-from-title.ts @@ -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'), '-'); +} From e970517f0382920f25fcae0cd298e83d4bf0734e Mon Sep 17 00:00:00 2001 From: Harshit Singh Date: Tue, 4 Aug 2026 23:48:06 +0530 Subject: [PATCH 2/8] feat: updated component name from title to use utility function --- packages/core/src/bundle/bundle-visitor.ts | 10 +++--- .../src/rules/oas3/component-name-unique.ts | 32 ++++++++++++++++++- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/core/src/bundle/bundle-visitor.ts b/packages/core/src/bundle/bundle-visitor.ts index 77c2b68aa4..8e8e41ac5c 100644 --- a/packages/core/src/bundle/bundle-visitor.ts +++ b/packages/core/src/bundle/bundle-visitor.ts @@ -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, @@ -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 Problem } from '../walk.js'; import { type ComponentNamesStrategy } from './bundle-document.js'; @@ -301,14 +301,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 === '') { @@ -360,7 +360,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; diff --git a/packages/core/src/rules/oas3/component-name-unique.ts b/packages/core/src/rules/oas3/component-name-unique.ts index e0be1241c0..e31f3ca1cb 100644 --- a/packages/core/src/rules/oas3/component-name-unique.ts +++ b/packages/core/src/rules/oas3/component-name-unique.ts @@ -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'; @@ -32,6 +35,8 @@ type ComponentsMapValue = { absolutePointers: Set; locations: Location[] export const ComponentNameUnique: Oas3Rule | Oas2Rule = (options) => { const components = new Map(); + const useTitleStrategy = options.strategy === 'title'; + let rootSourceRef: string; const typeNames: string[] = []; if (options.schemas !== 'off') { @@ -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); + } else { + addComponentFromAbsoluteLocation(typeName, resolvedRef.location); + } } }, }, Root: { + enter(_: AnyOas3Definition, { location }: UserContext) { + rootSourceRef = location.source.absoluteRef; + }, leave(root: AnyOas3Definition, ctx: UserContext) { components.forEach((value, key, _) => { if (value.absolutePointers.size > 1) { @@ -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 + ) { + return null; + } + + const { node } = resolved; + const title = isPlainObject(node) && isString(node.title) ? node.title.trim() : ''; + return title === '' ? null : componentNameFromTitle(title); + } }; function getOptionComponentNameForTypeName(typeName: string): string | null { From ec686033d4489055d1e30cdc51b8d453bbfdfb52 Mon Sep 17 00:00:00 2001 From: Harshit Singh Date: Tue, 4 Aug 2026 23:49:24 +0530 Subject: [PATCH 3/8] fix: updated bundle file for v2 --- docs/@v2/commands/bundle.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/@v2/commands/bundle.md b/docs/@v2/commands/bundle.md index cf19cf8246..fb5c68424d 100644 --- a/docs/@v2/commands/bundle.md +++ b/docs/@v2/commands/bundle.md @@ -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. From 1f25bc8983694dd06f7f49d940f1e084190d8f37 Mon Sep 17 00:00:00 2001 From: Harshit Singh Date: Wed, 5 Aug 2026 13:13:13 +0530 Subject: [PATCH 4/8] feat: added changeset and updated tests --- .changeset/vast-kids-add.md | 6 + docs/@v2/rules/oas/component-name-unique.md | 18 ++ .../__tests__/component-name-unique.test.ts | 186 ++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 .changeset/vast-kids-add.md diff --git a/.changeset/vast-kids-add.md b/.changeset/vast-kids-add.md new file mode 100644 index 0000000000..994b122114 --- /dev/null +++ b/.changeset/vast-kids-add.md @@ -0,0 +1,6 @@ +--- +'@redocly/cli': major +'@redocly/respect-core': major +--- + +Added a `strategy` option to the `component-name-unique` rule, matching the `--component-names-strategy` option of the `bundle` command. diff --git a/docs/@v2/rules/oas/component-name-unique.md b/docs/@v2/rules/oas/component-name-unique.md index b0bdfac425..1d79b9de89 100644 --- a/docs/@v2/rules/oas/component-name-unique.md +++ b/docs/@v2/rules/oas/component-name-unique.md @@ -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: @@ -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: 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 index b387202fbf..48964a9678 100644 --- a/packages/core/src/rules/oas3/__tests__/component-name-unique.test.ts +++ b/packages/core/src/rules/oas3/__tests__/component-name-unique.test.ts @@ -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": [], + }, + ] + `); + }); + }); }); From 8cbffec04b6e283374116bee34333c352168856e Mon Sep 17 00:00:00 2001 From: Harshit Singh Date: Wed, 5 Aug 2026 13:16:07 +0530 Subject: [PATCH 5/8] fix: updated to minor version --- .changeset/vast-kids-add.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/vast-kids-add.md b/.changeset/vast-kids-add.md index 994b122114..772bd6879d 100644 --- a/.changeset/vast-kids-add.md +++ b/.changeset/vast-kids-add.md @@ -1,6 +1,6 @@ --- -'@redocly/cli': major -'@redocly/respect-core': major +'@redocly/cli': minor +'@redocly/respect-core': minor --- Added a `strategy` option to the `component-name-unique` rule, matching the `--component-names-strategy` option of the `bundle` command. From b3888b9630ec8f513173b5378daf9f1597c6f97e Mon Sep 17 00:00:00 2001 From: Harshit Singh <73997189+harshit078@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:09:02 +0530 Subject: [PATCH 6/8] Update .changeset/vast-kids-add.md Co-authored-by: Viktor Sydor <31951646+kanoru3101@users.noreply.github.com> --- .changeset/vast-kids-add.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/vast-kids-add.md b/.changeset/vast-kids-add.md index 772bd6879d..2e74873dbd 100644 --- a/.changeset/vast-kids-add.md +++ b/.changeset/vast-kids-add.md @@ -1,6 +1,6 @@ --- '@redocly/cli': minor -'@redocly/respect-core': 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. From d72081184f21670d7f639aa6fd65f85501d990c2 Mon Sep 17 00:00:00 2001 From: Harshit Singh Date: Wed, 26 Aug 2026 17:28:19 +0530 Subject: [PATCH 7/8] fix: address comments --- .changeset/vast-kids-add.md | 2 + docs/@v2/rules/oas/component-name-unique.md | 7 +- packages/core/src/bundle/bundle-visitor.ts | 6 +- .../__tests__/component-name-unique.test.ts | 188 +++++++++++++++++- .../src/rules/oas3/component-name-unique.ts | 59 ++++-- .../src/utils/component-name-from-title.ts | 8 +- 6 files changed, 234 insertions(+), 36 deletions(-) diff --git a/.changeset/vast-kids-add.md b/.changeset/vast-kids-add.md index 2e74873dbd..0dddd5229e 100644 --- a/.changeset/vast-kids-add.md +++ b/.changeset/vast-kids-add.md @@ -4,3 +4,5 @@ --- Added a `strategy` option to the `component-name-unique` rule, matching the `--component-names-strategy` option of the `bundle` command. +Set it to `title` to check the component names that bundling derives from each schema's `title`. +With `strategy: title`, the rule also reports referenced schemas that have no `title`, because `bundle` can't name those and fails. diff --git a/docs/@v2/rules/oas/component-name-unique.md b/docs/@v2/rules/oas/component-name-unique.md index 1d79b9de89..b30ef45b26 100644 --- a/docs/@v2/rules/oas/component-name-unique.md +++ b/docs/@v2/rules/oas/component-name-unique.md @@ -64,9 +64,10 @@ 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. +The `title` strategy applies to every schema that `bundle` renames, which is every schema reached by a `$ref` that crosses a file boundary. +A referenced schema that has no `title` can't be named under this strategy, and `bundle` fails without producing a file. +The rule reports those schemas so you find them before bundling. +For the uniqueness check itself, such a schema still falls back to its file name, so a name collision is reported as well. ## Examples diff --git a/packages/core/src/bundle/bundle-visitor.ts b/packages/core/src/bundle/bundle-visitor.ts index 3af37945a5..10e42535f0 100644 --- a/packages/core/src/bundle/bundle-visitor.ts +++ b/packages/core/src/bundle/bundle-visitor.ts @@ -16,8 +16,6 @@ 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 { type Oas3Visitor, type Oas2Visitor } from '../visitors.js'; import { type UserContext, type ResolveResult, type NonUndefined, type Problem } from '../walk.js'; @@ -325,9 +323,7 @@ export function makeBundleVisitor({ componentsGroup: ComponentsGroup, ctx: UserContext ): { key: string; problem?: Problem } { - const { node } = target; - const title = isPlainObject(node) && isString(node.title) ? node.title.trim() : ''; - const key = componentNameFromTitle(title); + const { title, name: key } = componentNameFromTitle(target.node); const titleLocation = target.location.child('title'); if (title === '') { 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 index 48964a9678..36613503c0 100644 --- a/packages/core/src/rules/oas3/__tests__/component-name-unique.test.ts +++ b/packages/core/src/rules/oas3/__tests__/component-name-unique.test.ts @@ -1074,13 +1074,13 @@ describe('Oas3 component-name-unique', () => { { "location": [ { - "pointer": "#/", + "pointer": "#/title", "reportOnKey": false, "source": "/a/User.yaml", }, ], "message": "Component 'schemas/UserAccount' is not unique. It is also defined at: - - /b/Account.yaml", + - /b/Account.yaml#/title", "reference": "https://redocly.com/docs/cli/rules/oas/component-name-unique", "ruleId": "component-name-unique", "severity": "error", @@ -1089,13 +1089,179 @@ describe('Oas3 component-name-unique', () => { { "location": [ { - "pointer": "#/", + "pointer": "#/title", "reportOnKey": false, "source": "/b/Account.yaml", }, ], "message": "Component 'schemas/UserAccount' is not unique. It is also defined at: - - /a/User.yaml", + - /a/User.yaml#/title", + "reference": "https://redocly.com/docs/cli/rules/oas/component-name-unique", + "ruleId": "component-name-unique", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should report a root schema that another file refers to by title', async () => { + const rootBody = outdent` + openapi: 3.0.0 + paths: + /things: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '/Other.yaml' + components: + schemas: + Foo: + title: Bar thing + type: object + `; + const document = parseYamlToDocument(rootBody, '/foobar.yaml'); + const additionalDocuments = [ + { absoluteRef: '/foobar.yaml', body: rootBody }, + { + absoluteRef: '/Other.yaml', + body: outdent` + title: Bar thing + type: object + properties: + inner: + $ref: '/foobar.yaml#/components/schemas/Foo' + `, + }, + ]; + + const results = await lintDocumentForTest( + { 'component-name-unique': { severity: 'error', strategy: 'title' } }, + document, + additionalDocuments + ); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/components/schemas/Foo/title", + "reportOnKey": false, + "source": "/foobar.yaml", + }, + ], + "message": "Component 'schemas/BarThing' is not unique. It is also defined at: + - /Other.yaml#/title", + "reference": "https://redocly.com/docs/cli/rules/oas/component-name-unique", + "ruleId": "component-name-unique", + "severity": "error", + "suggest": [], + }, + { + "location": [ + { + "pointer": "#/title", + "reportOnKey": false, + "source": "/Other.yaml", + }, + ], + "message": "Component 'schemas/BarThing' is not unique. It is also defined at: + - /foobar.yaml#/components/schemas/Foo/title", + "reference": "https://redocly.com/docs/cli/rules/oas/component-name-unique", + "ruleId": "component-name-unique", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should not report schemas that only the root document refers to', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /orders: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + components: + schemas: + Order: + title: Order model + type: object + OrderModel: + title: Something else + type: object + `, + '/foobar.yaml' + ); + + const results = await lintDocumentForTest( + { 'component-name-unique': { severity: 'error', strategy: 'title' } }, + document, + [] + ); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should report a referenced schema without a title', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /carts: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '/Cart.yaml' + `, + '/foobar.yaml' + ); + const additionalDocuments = [ + { + absoluteRef: '/Cart.yaml', + body: outdent` + type: object + properties: + total: + type: number + `, + }, + ]; + + const results = await lintDocumentForTest( + { 'component-name-unique': { severity: 'error', strategy: 'title' } }, + document, + additionalDocuments + ); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/", + "reportOnKey": false, + "source": "/Cart.yaml", + }, + ], + "message": "Schema must define a \`title\` when using \`strategy: title\`. Bundling fails without it.", "reference": "https://redocly.com/docs/cli/rules/oas/component-name-unique", "ruleId": "component-name-unique", "severity": "error", @@ -1168,6 +1334,20 @@ describe('Oas3 component-name-unique', () => { "severity": "error", "suggest": [], }, + { + "location": [ + { + "pointer": "#/", + "reportOnKey": false, + "source": "/a/Order.yaml", + }, + ], + "message": "Schema must define a \`title\` when using \`strategy: title\`. Bundling fails without it.", + "reference": "https://redocly.com/docs/cli/rules/oas/component-name-unique", + "ruleId": "component-name-unique", + "severity": "error", + "suggest": [], + }, ] `); }); diff --git a/packages/core/src/rules/oas3/component-name-unique.ts b/packages/core/src/rules/oas3/component-name-unique.ts index e31f3ca1cb..066979bd2c 100644 --- a/packages/core/src/rules/oas3/component-name-unique.ts +++ b/packages/core/src/rules/oas3/component-name-unique.ts @@ -11,8 +11,6 @@ import type { 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'; @@ -35,6 +33,7 @@ type ComponentsMapValue = { absolutePointers: Set; locations: Location[] export const ComponentNameUnique: Oas3Rule | Oas2Rule = (options) => { const components = new Map(); + const schemasWithoutTitle = new Map(); const useTitleStrategy = options.strategy === 'title'; let rootSourceRef: string; @@ -54,18 +53,25 @@ export const ComponentNameUnique: Oas3Rule | Oas2Rule = (options) => { const rule: Oas3Visitor = { ref: { - leave(ref: OasRef, { type, resolve }: UserContext) { + leave(ref: OasRef, { type, resolve, location }: UserContext) { const typeName = type.name; if (typeNames.includes(typeName)) { const resolvedRef = resolve(ref); if (!resolvedRef.location) return; - const titleName = getTitleComponentName(typeName, resolvedRef); - if (titleName) { - addFoundComponent(typeName, titleName, resolvedRef.location); - } else { - addComponentFromAbsoluteLocation(typeName, resolvedRef.location); + if (usesTitleStrategy(typeName, location, resolvedRef.location)) { + const { title, name } = componentNameFromTitle(resolvedRef.node); + if (title) { + addFoundComponent(typeName, name, resolvedRef.location.child('title')); + return; + } + schemasWithoutTitle.set( + resolvedRef.location.absolutePointer.toString(), + resolvedRef.location + ); } + + addComponentFromAbsoluteLocation(typeName, resolvedRef.location); } }, }, @@ -96,6 +102,19 @@ export const ComponentNameUnique: Oas3Rule | Oas2Rule = (options) => { } } }); + + for (const location of schemasWithoutTitle.values()) { + const problem: Problem = { + message: + 'Schema must define a `title` when using `strategy: title`. Bundling fails without it.', + location, + reference: 'https://redocly.com/docs/cli/rules/oas/component-name-unique', + }; + if (options.schemas) { + problem.forceSeverity = options.schemas; + } + ctx.report(problem); + } }, }, }; @@ -161,21 +180,17 @@ export const ComponentNameUnique: Oas3Rule | Oas2Rule = (options) => { addFoundComponent(typeName, componentName, location); } - function getTitleComponentName( + function usesTitleStrategy( typeName: string, - resolved: { node: unknown; location: Location } - ): string | null { - if ( - !useTitleStrategy || - typeName !== TYPE_NAME_SCHEMA || - resolved.location.source.absoluteRef === rootSourceRef - ) { - return null; - } - - const { node } = resolved; - const title = isPlainObject(node) && isString(node.title) ? node.title.trim() : ''; - return title === '' ? null : componentNameFromTitle(title); + refLocation: Location, + targetLocation: Location + ): boolean { + return ( + useTitleStrategy && + typeName === TYPE_NAME_SCHEMA && + (refLocation.source.absoluteRef !== rootSourceRef || + targetLocation.source.absoluteRef !== rootSourceRef) + ); } }; diff --git a/packages/core/src/utils/component-name-from-title.ts b/packages/core/src/utils/component-name-from-title.ts index cd5d67cd54..2b83018451 100644 --- a/packages/core/src/utils/component-name-from-title.ts +++ b/packages/core/src/utils/component-name-from-title.ts @@ -1,6 +1,10 @@ import { COMPONENT_NAME_CHARS } from '../oas-types.js'; +import { isPlainObject } from './is-plain-object.js'; +import { isString } from './is-string.js'; import { toPascalCase } from './to-pascal-case.js'; -export function componentNameFromTitle(title: string): string { - return toPascalCase(title).replace(new RegExp(`[^${COMPONENT_NAME_CHARS}]`, 'g'), '-'); +export function componentNameFromTitle(node: unknown): { title: string; name: string } { + const title = isPlainObject(node) && isString(node.title) ? node.title.trim() : ''; + const name = toPascalCase(title).replace(new RegExp(`[^${COMPONENT_NAME_CHARS}]`, 'g'), '-'); + return { title, name }; } From ff9deed8f6058e8c38c49ec2b37e5b4898d8ed4b Mon Sep 17 00:00:00 2001 From: Harshit Singh Date: Wed, 26 Aug 2026 18:17:31 +0530 Subject: [PATCH 8/8] fix: address cursor comment --- .../__tests__/component-name-unique.test.ts | 51 +++++++++++++++++-- .../src/rules/oas3/component-name-unique.ts | 36 +++++++------ 2 files changed, 68 insertions(+), 19 deletions(-) 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 index 36613503c0..4f055f783b 100644 --- a/packages/core/src/rules/oas3/__tests__/component-name-unique.test.ts +++ b/packages/core/src/rules/oas3/__tests__/component-name-unique.test.ts @@ -1080,7 +1080,7 @@ describe('Oas3 component-name-unique', () => { }, ], "message": "Component 'schemas/UserAccount' is not unique. It is also defined at: - - /b/Account.yaml#/title", + - /b/Account.yaml", "reference": "https://redocly.com/docs/cli/rules/oas/component-name-unique", "ruleId": "component-name-unique", "severity": "error", @@ -1095,7 +1095,7 @@ describe('Oas3 component-name-unique', () => { }, ], "message": "Component 'schemas/UserAccount' is not unique. It is also defined at: - - /a/User.yaml#/title", + - /a/User.yaml", "reference": "https://redocly.com/docs/cli/rules/oas/component-name-unique", "ruleId": "component-name-unique", "severity": "error", @@ -1105,6 +1105,49 @@ describe('Oas3 component-name-unique', () => { `); }); + it('should not report a root schema whose title matches its own component name', async () => { + const rootBody = outdent` + openapi: 3.0.0 + paths: + /things: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '/Other.yaml' + components: + schemas: + BarThing: + title: Bar thing + type: object + `; + const document = parseYamlToDocument(rootBody, '/foobar.yaml'); + const additionalDocuments = [ + { absoluteRef: '/foobar.yaml', body: rootBody }, + { + absoluteRef: '/Other.yaml', + body: outdent` + title: Other thing + type: object + properties: + inner: + $ref: '/foobar.yaml#/components/schemas/BarThing' + `, + }, + ]; + + const results = await lintDocumentForTest( + { 'component-name-unique': { severity: 'error', strategy: 'title' } }, + document, + additionalDocuments + ); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + it('should report a root schema that another file refers to by title', async () => { const rootBody = outdent` openapi: 3.0.0 @@ -1156,7 +1199,7 @@ describe('Oas3 component-name-unique', () => { }, ], "message": "Component 'schemas/BarThing' is not unique. It is also defined at: - - /Other.yaml#/title", + - /Other.yaml", "reference": "https://redocly.com/docs/cli/rules/oas/component-name-unique", "ruleId": "component-name-unique", "severity": "error", @@ -1171,7 +1214,7 @@ describe('Oas3 component-name-unique', () => { }, ], "message": "Component 'schemas/BarThing' is not unique. It is also defined at: - - /foobar.yaml#/components/schemas/Foo/title", + - /foobar.yaml#/components/schemas/Foo", "reference": "https://redocly.com/docs/cli/rules/oas/component-name-unique", "ruleId": "component-name-unique", "severity": "error", diff --git a/packages/core/src/rules/oas3/component-name-unique.ts b/packages/core/src/rules/oas3/component-name-unique.ts index 066979bd2c..95c4824853 100644 --- a/packages/core/src/rules/oas3/component-name-unique.ts +++ b/packages/core/src/rules/oas3/component-name-unique.ts @@ -29,7 +29,7 @@ const TYPE_NAME_TO_OPTION_COMPONENT_NAME: { [key: string]: string } = { [TYPE_NAME_REQUEST_BODY]: 'requestBodies', }; -type ComponentsMapValue = { absolutePointers: Set; locations: Location[] }; +type ComponentsMapValue = Map; export const ComponentNameUnique: Oas3Rule | Oas2Rule = (options) => { const components = new Map(); @@ -62,7 +62,12 @@ export const ComponentNameUnique: Oas3Rule | Oas2Rule = (options) => { if (usesTitleStrategy(typeName, location, resolvedRef.location)) { const { title, name } = componentNameFromTitle(resolvedRef.node); if (title) { - addFoundComponent(typeName, name, resolvedRef.location.child('title')); + addFoundComponent( + typeName, + name, + resolvedRef.location, + resolvedRef.location.child('title') + ); return; } schemasWithoutTitle.set( @@ -80,14 +85,14 @@ export const ComponentNameUnique: Oas3Rule | Oas2Rule = (options) => { rootSourceRef = location.source.absoluteRef; }, leave(root: AnyOas3Definition, ctx: UserContext) { - components.forEach((value, key, _) => { - if (value.absolutePointers.size > 1) { + components.forEach((entry, key, _) => { + if (entry.size > 1) { const component = getComponentFromKey(key); const optionComponentName = getOptionComponentNameForTypeName(component.typeName); const componentSeverity = optionComponentName ? options[optionComponentName] : null; - for (const location of value.locations) { - const definitions = Array.from(value.absolutePointers) - .filter((v) => v !== location.absolutePointer.toString()) + for (const [absolutePointer, location] of entry) { + const definitions = Array.from(entry.keys()) + .filter((v) => v !== absolutePointer) .map((v) => `- ${v}`) .join('\n'); const problem: Problem = { @@ -161,16 +166,17 @@ export const ComponentNameUnique: Oas3Rule | Oas2Rule = (options) => { return componentName; } - function addFoundComponent(typeName: string, componentName: string, location: Location): void { + function addFoundComponent( + typeName: string, + componentName: string, + location: Location, + reportLocation: Location = location + ): void { const key = getKeyForComponent(typeName, componentName); - const entry: ComponentsMapValue = components.get(key) ?? { - absolutePointers: new Set(), - locations: [], - }; + const entry: ComponentsMapValue = components.get(key) ?? new Map(); const absoluteLocation = location.absolutePointer.toString(); - if (!entry.absolutePointers.has(absoluteLocation)) { - entry.absolutePointers.add(absoluteLocation); - entry.locations.push(location); + if (!entry.has(absoluteLocation)) { + entry.set(absoluteLocation, reportLocation); } components.set(key, entry); }