From 366010a9b6f1adcff43ab1d38564e69e108744ee Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Thu, 9 Apr 2026 17:35:54 +0300 Subject: [PATCH 01/19] feat: setup the postbundle decorators and refactor remove-unused-components --- packages/core/src/bundle/bundle-document.ts | 23 +++- .../src/bundle/run-post-bundle-decorators.ts | 31 ++++++ packages/core/src/config/builtIn.ts | 14 ++- packages/core/src/config/config.ts | 54 +++++++++ packages/core/src/config/types.ts | 1 + packages/core/src/decorators/oas2/index.ts | 5 +- .../oas2/remove-unused-components.ts | 104 +++++++++--------- packages/core/src/decorators/oas3/index.ts | 5 +- .../oas3/remove-unused-components.ts | 102 +++++++++-------- packages/core/src/index.ts | 1 + .../openapi.yaml | 21 ++++ .../parameters.yaml | 6 + .../schemas.yaml | 4 + .../openapi.yaml | 21 ++++ .../parameters.yaml | 6 + .../redocly.yaml | 6 + .../schemas.yaml | 4 + .../snapshot.txt | 32 ++++++ .../openapi.yaml | 21 ++++ .../parameters.yaml | 6 + .../redocly.yaml | 6 + .../schemas.yaml | 2 + .../snapshot.txt | 32 ++++++ .../oas3-parameter-ref-to-schema/openapi.yaml | 14 +++ .../parameters.yaml | 6 + .../oas3-parameter-ref-to-schema/redocly.yaml | 6 + .../oas3-parameter-ref-to-schema/schemas.yaml | 2 + .../oas3-parameter-ref-to-schema/snapshot.txt | 28 +++++ tests/e2e/bundle/bundle.test.ts | 11 ++ 29 files changed, 464 insertions(+), 110 deletions(-) create mode 100644 packages/core/src/bundle/run-post-bundle-decorators.ts create mode 100644 parameter-reference-to-schema-ext/openapi.yaml create mode 100644 parameter-reference-to-schema-ext/parameters.yaml create mode 100644 parameter-reference-to-schema-ext/schemas.yaml create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/openapi.yaml create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/parameters.yaml create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/redocly.yaml create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/schemas.yaml create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/snapshot.txt create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/openapi.yaml create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/parameters.yaml create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/redocly.yaml create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/schemas.yaml create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/snapshot.txt create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/openapi.yaml create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/parameters.yaml create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/redocly.yaml create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/schemas.yaml create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/snapshot.txt diff --git a/packages/core/src/bundle/bundle-document.ts b/packages/core/src/bundle/bundle-document.ts index 3a645e5433..385548e6ff 100644 --- a/packages/core/src/bundle/bundle-document.ts +++ b/packages/core/src/bundle/bundle-document.ts @@ -1,4 +1,5 @@ import { makeBundleVisitor } from '../bundle/bundle-visitor.js'; +import { runPostBundleDecorators } from '../bundle/run-post-bundle-decorators.js'; import { type Config } from '../config/config.js'; import { initRules } from '../config/rules.js'; import { type RuleSeverity } from '../config/types.js'; @@ -57,7 +58,10 @@ export async function bundleDocument(opts: { const normalizedTypes = normalizeTypes(config.extendTypes(types, specVersion), config); const preprocessors = initRules(rules, config, 'preprocessors', specVersion); - const decorators = initRules(rules, config, 'decorators', specVersion); + const regularDecorators = initRules(rules, config, 'decorators', specVersion); + + const postBundleRules = config.getPostBundleDecoratorsForSpecVersion(specMajorVersion); + const postBundleDecorators = initRules(postBundleRules, config, 'decorators', specVersion); const ctx: BundleContext = { problems: [], @@ -67,8 +71,11 @@ export async function bundleDocument(opts: { visitorsData: {}, }; - if (removeUnusedComponents && !decorators.some((d) => d.ruleId === 'remove-unused-components')) { - decorators.push({ + if ( + removeUnusedComponents && + !postBundleDecorators.some((d) => d.ruleId === 'remove-unused-components') + ) { + postBundleDecorators.push({ severity: 'error', ruleId: 'remove-unused-components', visitor: @@ -114,7 +121,7 @@ export async function bundleDocument(opts: { componentRenamingConflicts, }), }, - ...decorators, + ...regularDecorators, ], normalizedTypes ); @@ -127,6 +134,14 @@ export async function bundleDocument(opts: { ctx, }); + await runPostBundleDecorators({ + document, + normalizedTypes, + postBundleDecorators, + externalRefResolver, + ctx, + }); + return { bundle: document, problems: ctx.problems.map((problem) => config.addProblemToIgnore(problem)), diff --git a/packages/core/src/bundle/run-post-bundle-decorators.ts b/packages/core/src/bundle/run-post-bundle-decorators.ts new file mode 100644 index 0000000000..3ed15310f2 --- /dev/null +++ b/packages/core/src/bundle/run-post-bundle-decorators.ts @@ -0,0 +1,31 @@ +import { resolveDocument, type Document, type BaseResolver } from '../resolve.js'; +import { type NormalizedNodeType } from '../types/index.js'; +import { normalizeVisitors } from '../visitors.js'; +import { walkDocument, type WalkContext, type ProblemSeverity } from '../walk.js'; + +export async function runPostBundleDecorators(opts: { + document: Document; + normalizedTypes: Record; + postBundleDecorators: { severity: ProblemSeverity; ruleId: string; visitor: any }[]; + externalRefResolver: BaseResolver; + ctx: WalkContext; +}): Promise { + const { document, normalizedTypes, postBundleDecorators, externalRefResolver, ctx } = opts; + + if (postBundleDecorators.length === 0) return; + + const postBundleRefMap = await resolveDocument({ + rootDocument: document, + rootType: normalizedTypes.Root, + externalRefResolver, + }); + const postBundleVisitors = normalizeVisitors(postBundleDecorators, normalizedTypes); + + walkDocument({ + document, + rootType: normalizedTypes.Root, + normalizedVisitors: postBundleVisitors, + resolvedRefMap: postBundleRefMap, + ctx, + }); +} diff --git a/packages/core/src/config/builtIn.ts b/packages/core/src/config/builtIn.ts index c8a975724b..df0fdd84d0 100644 --- a/packages/core/src/config/builtIn.ts +++ b/packages/core/src/config/builtIn.ts @@ -1,8 +1,14 @@ import { decorators as arazzo1Decorators } from '../decorators/arazzo/index.js'; import { decorators as async2Decorators } from '../decorators/async2/index.js'; import { decorators as async3Decorators } from '../decorators/async3/index.js'; -import { decorators as oas2Decorators } from '../decorators/oas2/index.js'; -import { decorators as oas3Decorators } from '../decorators/oas3/index.js'; +import { + decorators as oas2Decorators, + postBundleDecorators as oas2PostBundleDecorators, +} from '../decorators/oas2/index.js'; +import { + decorators as oas3Decorators, + postBundleDecorators as oas3PostBundleDecorators, +} from '../decorators/oas3/index.js'; import { decorators as openrpc1Decorators } from '../decorators/openrpc/index.js'; import { decorators as overlay1Decorators } from '../decorators/overlay1/index.js'; import { @@ -71,5 +77,9 @@ export const defaultPlugin: Plugin<'built-in'> = { overlay1: overlay1Decorators, openrpc1: openrpc1Decorators, }, + postBundleDecorators: { + oas3: oas3PostBundleDecorators, + oas2: oas2PostBundleDecorators, + }, configs: builtInConfigs, }; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 7ef6647bbe..86800b35d5 100755 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -397,6 +397,60 @@ export class Config { } } + getPostBundleDecoratorsForSpecVersion(version: SpecMajorVersion) { + switch (version) { + case 'oas3': { + const sets: Oas3RuleSet[] = []; + this.plugins.forEach( + (p) => p.postBundleDecorators?.oas3 && sets.push(p.postBundleDecorators.oas3) + ); + return sets; + } + case 'oas2': { + const sets: Oas2RuleSet[] = []; + this.plugins.forEach( + (p) => p.postBundleDecorators?.oas2 && sets.push(p.postBundleDecorators.oas2) + ); + return sets; + } + case 'async2': { + const sets: Async2RuleSet[] = []; + this.plugins.forEach( + (p) => p.postBundleDecorators?.async2 && sets.push(p.postBundleDecorators.async2) + ); + return sets; + } + case 'async3': { + const sets: Async3RuleSet[] = []; + this.plugins.forEach( + (p) => p.postBundleDecorators?.async3 && sets.push(p.postBundleDecorators.async3) + ); + return sets; + } + case 'arazzo1': { + const sets: Arazzo1RuleSet[] = []; + this.plugins.forEach( + (p) => p.postBundleDecorators?.arazzo1 && sets.push(p.postBundleDecorators.arazzo1) + ); + return sets; + } + case 'overlay1': { + const sets: Overlay1RuleSet[] = []; + this.plugins.forEach( + (p) => p.postBundleDecorators?.overlay1 && sets.push(p.postBundleDecorators.overlay1) + ); + return sets; + } + case 'openrpc1': { + const sets: OpenRpc1RuleSet[] = []; + this.plugins.forEach( + (p) => p.postBundleDecorators?.openrpc1 && sets.push(p.postBundleDecorators.openrpc1) + ); + return sets; + } + } + } + skipRules(rules?: string[]) { for (const ruleId of rules || []) { for (const version of specVersions) { diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index d91ece0f99..f34f865929 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -181,6 +181,7 @@ export type Plugin = { rules?: RulesConfig; preprocessors?: PreprocessorsConfig; decorators?: DecoratorsConfig; + postBundleDecorators?: DecoratorsConfig; typeExtension?: TypeExtensionsConfig; assertions?: AssertionsConfig; diff --git a/packages/core/src/decorators/oas2/index.ts b/packages/core/src/decorators/oas2/index.ts index 7130b186e9..d12ce261d9 100644 --- a/packages/core/src/decorators/oas2/index.ts +++ b/packages/core/src/decorators/oas2/index.ts @@ -16,5 +16,8 @@ export const decorators = { 'remove-x-internal': RemoveXInternal as Oas2Decorator, 'filter-in': FilterIn as Oas2Decorator, 'filter-out': FilterOut as Oas2Decorator, - 'remove-unused-components': RemoveUnusedComponents, // always the last one +}; + +export const postBundleDecorators = { + 'remove-unused-components': RemoveUnusedComponents, }; diff --git a/packages/core/src/decorators/oas2/remove-unused-components.ts b/packages/core/src/decorators/oas2/remove-unused-components.ts index cc9331d33c..3c907711bd 100644 --- a/packages/core/src/decorators/oas2/remove-unused-components.ts +++ b/packages/core/src/decorators/oas2/remove-unused-components.ts @@ -1,78 +1,82 @@ -import type { Location } from '../../ref-utils.js'; import type { Oas2Components, Oas2Definition } from '../../typings/swagger.js'; import { isEmptyObject } from '../../utils/is-empty-object.js'; import type { Oas2Decorator } from '../../visitors.js'; +const OAS2_COMPONENT_TYPES: (keyof Oas2Components)[] = [ + 'definitions', + 'parameters', + 'responses', + 'securityDefinitions', +]; + export const RemoveUnusedComponents: Oas2Decorator = () => { const components = new Map< string, - { usedIn: Location[]; componentType?: keyof Oas2Components; name: string } + { usedIn: (string | undefined)[]; componentType?: keyof Oas2Components; name: string } >(); - function registerComponent( - location: Location, - componentType: keyof Oas2Components, - name: string - ): void { - components.set(location.absolutePointer, { - usedIn: components.get(location.absolutePointer)?.usedIn ?? [], + function registerComponent(componentType: keyof Oas2Components, name: string): void { + const pointer = `#/${componentType}/${name}`; + components.set(pointer, { + usedIn: components.get(pointer)?.usedIn ?? [], componentType, name, }); } - function removeUnusedComponents(root: Oas2Definition, removedPaths: string[]): number { - const removedLengthStart = removedPaths.length; + function getComponentPointer(pointer: string): string | undefined { + for (const type of OAS2_COMPONENT_TYPES) { + const prefix = `#/${type}/`; + if (pointer.startsWith(prefix)) { + const name = pointer.slice(prefix.length).split('/')[0]; + if (name) return `#/${type}/${name}`; + } + } + return undefined; + } + + function removeUnusedComponents( + root: Oas2Definition, + removedKeys: Set = new Set() + ): number { + const countBefore = removedKeys.size; - for (const [path, { usedIn, name, componentType }] of components) { + for (const [key, { usedIn, name, componentType }] of components) { const used = usedIn.some( - (location) => - !removedPaths.some( - (removed) => - // Check if the current location's absolute pointer starts with the 'removed' path - // and either its length matches exactly with 'removed' or the character after the 'removed' path is a '/' - location.absolutePointer.startsWith(removed) && - (location.absolutePointer.length === removed.length || - location.absolutePointer[removed.length] === '/') - ) + (sourceKey) => sourceKey === undefined || !removedKeys.has(sourceKey) ); + if (!used && componentType) { - removedPaths.push(path); + removedKeys.add(key); delete root[componentType]![name]; - components.delete(path); - + components.delete(key); if (isEmptyObject(root[componentType])) { delete root[componentType]; } } } - return removedPaths.length > removedLengthStart - ? removeUnusedComponents(root, removedPaths) - : removedPaths.length; + return removedKeys.size > countBefore + ? removeUnusedComponents(root, removedKeys) + : removedKeys.size; } return { ref: { - leave(ref, { location, type, resolve, key }) { + leave(ref, { location, type }) { if (['Schema', 'Parameter', 'Response', 'SecurityScheme'].includes(type.name)) { - const resolvedRef = resolve(ref); - if (!resolvedRef.location) return; - - const [fileLocation, localPointer] = resolvedRef.location.absolutePointer.split('#', 2); - if (!localPointer) return; - - const componentLevelLocalPointer = localPointer.split('/').slice(0, 3).join('/'); - const pointer = `${fileLocation}#${componentLevelLocalPointer}`; + const targetPointer = getComponentPointer(ref.$ref); + if (!targetPointer) return; - const registered = components.get(pointer); + const sourcePointer = getComponentPointer(location.pointer); + const registered = components.get(targetPointer); if (registered) { - registered.usedIn.push(location); + registered.usedIn.push(sourcePointer); } else { - components.set(pointer, { - usedIn: [location], - name: key.toString(), + components.set(targetPointer, { + usedIn: [sourcePointer], + name: ref.$ref.split('/').pop()!, }); } } @@ -81,29 +85,29 @@ export const RemoveUnusedComponents: Oas2Decorator = () => { Root: { leave(root, ctx) { const data = ctx.getVisitorData() as { removedCount: number }; - data.removedCount = removeUnusedComponents(root, []); + data.removedCount = removeUnusedComponents(root); }, }, NamedSchemas: { - Schema(schema, { location, key }) { + Schema(schema, { key }) { if (!schema.allOf) { - registerComponent(location, 'definitions', key.toString()); + registerComponent('definitions', key.toString()); } }, }, NamedParameters: { - Parameter(_parameter, { location, key }) { - registerComponent(location, 'parameters', key.toString()); + Parameter(_parameter, { key }) { + registerComponent('parameters', key.toString()); }, }, NamedResponses: { - Response(_response, { location, key }) { - registerComponent(location, 'responses', key.toString()); + Response(_response, { key }) { + registerComponent('responses', key.toString()); }, }, NamedSecuritySchemes: { - SecurityScheme(_securityScheme, { location, key }) { - registerComponent(location, 'securityDefinitions', key.toString()); + SecurityScheme(_securityScheme, { key }) { + registerComponent('securityDefinitions', key.toString()); }, }, }; diff --git a/packages/core/src/decorators/oas3/index.ts b/packages/core/src/decorators/oas3/index.ts index 4f8b0675a2..d983f8b74c 100644 --- a/packages/core/src/decorators/oas3/index.ts +++ b/packages/core/src/decorators/oas3/index.ts @@ -19,5 +19,8 @@ export const decorators = { 'filter-in': FilterIn as Oas3Decorator, 'filter-out': FilterOut as Oas3Decorator, 'media-type-examples-override': MediaTypeExamplesOverride as Oas3Decorator, - 'remove-unused-components': RemoveUnusedComponents, // always the last one +}; + +export const postBundleDecorators = { + 'remove-unused-components': RemoveUnusedComponents, }; diff --git a/packages/core/src/decorators/oas3/remove-unused-components.ts b/packages/core/src/decorators/oas3/remove-unused-components.ts index b31b0ced93..d019eeb78c 100644 --- a/packages/core/src/decorators/oas3/remove-unused-components.ts +++ b/packages/core/src/decorators/oas3/remove-unused-components.ts @@ -1,4 +1,3 @@ -import type { Location } from '../../ref-utils.js'; import type { Oas3Definition, Oas3_1Definition, @@ -14,40 +13,44 @@ import type { Oas3Decorator } from '../../visitors.js'; type AnyOas3Definition = Oas3Definition | Oas3_1Definition | Oas3_2Definition; type AnyOas3ComponentsKey = keyof Oas3Components | keyof Oas3_1Components | keyof Oas3_2Components; +const COMPONENTS_PREFIX = '#/components/'; + +function getContainingComponentKey(pointer: string): string | undefined { + if (!pointer.startsWith(COMPONENTS_PREFIX)) return undefined; + const rest = pointer.slice(COMPONENTS_PREFIX.length); + const parts = rest.split('/'); + if (parts.length < 2) return undefined; + return `${parts[0]}/${parts[1]}`; +} + export const RemoveUnusedComponents: Oas3Decorator = () => { const components = new Map< string, { - usedIn: Location[]; + usedIn: (string | undefined)[]; componentType?: AnyOas3ComponentsKey; name: string; } >(); - function registerComponent( - location: Location, - componentType: AnyOas3ComponentsKey, - name: string - ): void { - components.set(location.absolutePointer, { - usedIn: components.get(location.absolutePointer)?.usedIn ?? [], + function registerComponent(componentType: AnyOas3ComponentsKey, name: string): void { + const key = `${componentType}/${name}`; + components.set(key, { + usedIn: components.get(key)?.usedIn ?? [], componentType, name, }); } - function removeUnusedComponents(root: AnyOas3Definition, removedPaths: string[]): number { - const removedLengthStart = removedPaths.length; + function removeUnusedComponents( + root: AnyOas3Definition, + removedKeys: Set = new Set() + ): number { + const countBefore = removedKeys.size; - for (const [path, { usedIn, name, componentType }] of components) { + for (const [key, { usedIn, name, componentType }] of components) { const used = usedIn.some( - (location) => - !removedPaths.some( - (removed) => - location.absolutePointer.startsWith(removed) && - (location.absolutePointer.length === removed.length || - location.absolutePointer[removed.length] === '/') - ) + (sourceKey) => sourceKey === undefined || !removedKeys.has(sourceKey) ); if ( @@ -56,24 +59,24 @@ export const RemoveUnusedComponents: Oas3Decorator = () => { root.components && hasComponent(root.components, componentType) ) { - removedPaths.push(path); + removedKeys.add(key); const componentChild = root.components[componentType]; delete componentChild![name]; - components.delete(path); + components.delete(key); if (isEmptyObject(componentChild)) { delete root.components[componentType]; } } } - return removedPaths.length > removedLengthStart - ? removeUnusedComponents(root, removedPaths) - : removedPaths.length; + return removedKeys.size > countBefore + ? removeUnusedComponents(root, removedKeys) + : removedKeys.size; } return { ref: { - leave(ref, { location, type, resolve, key }) { + leave(ref, { location, type, key }) { if ( [ 'Schema', @@ -85,22 +88,17 @@ export const RemoveUnusedComponents: Oas3Decorator = () => { 'MediaTypesMap', ].includes(type.name) ) { - const resolvedRef = resolve(ref); - if (!resolvedRef.location) return; - - const [fileLocation, localPointer] = resolvedRef.location.absolutePointer.split('#', 2); - if (!localPointer) return; - - const componentLevelLocalPointer = localPointer.split('/').slice(0, 4).join('/'); - const pointer = `${fileLocation}#${componentLevelLocalPointer}`; + const targetPointer = getContainingComponentKey(ref.$ref); + if (!targetPointer) return; - const registered = components.get(pointer); + const sourcePointer = getContainingComponentKey(location.pointer); + const registered = components.get(targetPointer); if (registered) { - registered.usedIn.push(location); + registered.usedIn.push(sourcePointer); } else { - components.set(pointer, { - usedIn: [location], + components.set(targetPointer, { + usedIn: [sourcePointer], name: key.toString(), }); } @@ -110,7 +108,7 @@ export const RemoveUnusedComponents: Oas3Decorator = () => { Root: { leave(root, ctx) { const data = ctx.getVisitorData() as { removedCount: number }; - data.removedCount = removeUnusedComponents(root, []); + data.removedCount = removeUnusedComponents(root); if (isEmptyObject(root.components)) { delete root.components; @@ -118,40 +116,40 @@ export const RemoveUnusedComponents: Oas3Decorator = () => { }, }, NamedSchemas: { - Schema(schema, { location, key }) { + Schema(schema, { key }) { if (!schema.allOf) { - registerComponent(location, 'schemas', key.toString()); + registerComponent('schemas', key.toString()); } }, }, NamedParameters: { - Parameter(_parameter, { location, key }) { - registerComponent(location, 'parameters', key.toString()); + Parameter(_parameter, { key }) { + registerComponent('parameters', key.toString()); }, }, NamedResponses: { - Response(_response, { location, key }) { - registerComponent(location, 'responses', key.toString()); + Response(_response, { key }) { + registerComponent('responses', key.toString()); }, }, NamedExamples: { - Example(_example, { location, key }) { - registerComponent(location, 'examples', key.toString()); + Example(_example, { key }) { + registerComponent('examples', key.toString()); }, }, NamedRequestBodies: { - RequestBody(_requestBody, { location, key }) { - registerComponent(location, 'requestBodies', key.toString()); + RequestBody(_requestBody, { key }) { + registerComponent('requestBodies', key.toString()); }, }, NamedHeaders: { - Header(_header, { location, key }) { - registerComponent(location, 'headers', key.toString()); + Header(_header, { key }) { + registerComponent('headers', key.toString()); }, }, NamedMediaTypes: { - MediaTypesMap(_mediaTypesMap, { location, key }) { - registerComponent(location, 'mediaTypes', key.toString()); + MediaTypesMap(_mediaTypesMap, { key }) { + registerComponent('mediaTypes', key.toString()); }, }, }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6283dc1e48..30af0153a9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -119,6 +119,7 @@ export { lint, lint as validate, lintDocument, lintFromString, lintConfig } from export { lintEntityFile, lintEntityWithScorecardLevel, lintSchema } from './lint-entity.js'; export { bundle, bundleFromString, type BundleResult } from './bundle/bundle.js'; export { bundleDocument } from './bundle/bundle-document.js'; +export { runPostBundleDecorators } from './bundle/run-post-bundle-decorators.js'; export { mapTypeToComponent } from './bundle/bundle-visitor.js'; export { type Assertions, type Assertion } from './rules/common/assertions/index.js'; export { logger, type LoggerInterface } from './logger.js'; diff --git a/parameter-reference-to-schema-ext/openapi.yaml b/parameter-reference-to-schema-ext/openapi.yaml new file mode 100644 index 0000000000..40fbdece3b --- /dev/null +++ b/parameter-reference-to-schema-ext/openapi.yaml @@ -0,0 +1,21 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 1.0.0 +paths: + /users: + get: + operationId: getUsers + summary: Get a list of users + parameters: + - $ref: parameters.yaml#/Param + responses: + '200': + description: A list of users + content: + application/json: + schema: {} +components: + schemas: + User: + $ref: schemas.yaml#/User diff --git a/parameter-reference-to-schema-ext/parameters.yaml b/parameter-reference-to-schema-ext/parameters.yaml new file mode 100644 index 0000000000..04188a821e --- /dev/null +++ b/parameter-reference-to-schema-ext/parameters.yaml @@ -0,0 +1,6 @@ +Param: + name: param + in: query + required: true + schema: + $ref: schemas.yaml#/Schema diff --git a/parameter-reference-to-schema-ext/schemas.yaml b/parameter-reference-to-schema-ext/schemas.yaml new file mode 100644 index 0000000000..35c1261756 --- /dev/null +++ b/parameter-reference-to-schema-ext/schemas.yaml @@ -0,0 +1,4 @@ +/clSchema: + type: string +User: + type: string diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/openapi.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/openapi.yaml new file mode 100644 index 0000000000..c4bd8c36cf --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/openapi.yaml @@ -0,0 +1,21 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 1.0.0 +paths: + /users: + get: + operationId: getUsers + summary: Get a list of users + parameters: + - $ref: parameters.yaml#/Param + responses: + '200': + description: A list of users + content: + application/json: + schema: {} +components: + schemas: + User: + $ref: schemas.yaml#/User diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/parameters.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/parameters.yaml new file mode 100644 index 0000000000..33e16a2f82 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/parameters.yaml @@ -0,0 +1,6 @@ +Param: + name: param + in: query + required: true + schema: + $ref: schemas.yaml#/Schema \ No newline at end of file diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/redocly.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/redocly.yaml new file mode 100644 index 0000000000..4af1376a34 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/redocly.yaml @@ -0,0 +1,6 @@ +apis: + test-api: + root: openapi.yaml + +decorators: + remove-unused-components: on \ No newline at end of file diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/schemas.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/schemas.yaml new file mode 100644 index 0000000000..79b60e8a13 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/schemas.yaml @@ -0,0 +1,4 @@ +Schema: + type: string +User: + type: string \ No newline at end of file diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/snapshot.txt b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/snapshot.txt new file mode 100644 index 0000000000..792cc34683 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/snapshot.txt @@ -0,0 +1,32 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 1.0.0 +paths: + /users: + get: + operationId: getUsers + summary: Get a list of users + parameters: + - $ref: '#/components/parameters/Param' + responses: + '200': + description: A list of users + content: + application/json: + schema: {} +components: + schemas: + Schema: + type: string + parameters: + Param: + name: param + in: query + required: true + schema: + $ref: '#/components/schemas/Schema' + +bundling openapi.yaml using configuration for api 'test-api'... +๐Ÿ“ฆ Created a bundle for openapi.yaml at stdout ms. +๐Ÿงน Removed 1 unused components. diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/openapi.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/openapi.yaml new file mode 100644 index 0000000000..9c0ef83c77 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/openapi.yaml @@ -0,0 +1,21 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 +paths: + /users: + get: + operationId: getUsers + summary: Get a list of users + parameters: + - $ref: parameters.yaml#/Param + responses: + '200': + description: A list of users + content: + application/json: + schema: {} +components: + schemas: + User: + $ref: schemas.yaml#/Schema \ No newline at end of file diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/parameters.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/parameters.yaml new file mode 100644 index 0000000000..33e16a2f82 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/parameters.yaml @@ -0,0 +1,6 @@ +Param: + name: param + in: query + required: true + schema: + $ref: schemas.yaml#/Schema \ No newline at end of file diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/redocly.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/redocly.yaml new file mode 100644 index 0000000000..4af1376a34 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/redocly.yaml @@ -0,0 +1,6 @@ +apis: + test-api: + root: openapi.yaml + +decorators: + remove-unused-components: on \ No newline at end of file diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/schemas.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/schemas.yaml new file mode 100644 index 0000000000..b8456dfcc5 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/schemas.yaml @@ -0,0 +1,2 @@ +Schema: + type: string \ No newline at end of file diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/snapshot.txt b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/snapshot.txt new file mode 100644 index 0000000000..ae192b2ad8 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/snapshot.txt @@ -0,0 +1,32 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 +paths: + /users: + get: + operationId: getUsers + summary: Get a list of users + parameters: + - $ref: '#/components/parameters/Param' + responses: + '200': + description: A list of users + content: + application/json: + schema: {} +components: + schemas: + Schema: + type: string + parameters: + Param: + name: param + in: query + required: true + schema: + $ref: '#/components/schemas/Schema' + +bundling openapi.yaml using configuration for api 'test-api'... +๐Ÿ“ฆ Created a bundle for openapi.yaml at stdout ms. +๐Ÿงน Removed 1 unused components. diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/openapi.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/openapi.yaml new file mode 100644 index 0000000000..6956175035 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/openapi.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 +paths: + /users: + get: + parameters: + - $ref: parameters.yaml#/Param + responses: + '200': + content: + application/json: + schema: {} \ No newline at end of file diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/parameters.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/parameters.yaml new file mode 100644 index 0000000000..33e16a2f82 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/parameters.yaml @@ -0,0 +1,6 @@ +Param: + name: param + in: query + required: true + schema: + $ref: schemas.yaml#/Schema \ No newline at end of file diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/redocly.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/redocly.yaml new file mode 100644 index 0000000000..4af1376a34 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/redocly.yaml @@ -0,0 +1,6 @@ +apis: + test-api: + root: openapi.yaml + +decorators: + remove-unused-components: on \ No newline at end of file diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/schemas.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/schemas.yaml new file mode 100644 index 0000000000..b8456dfcc5 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/schemas.yaml @@ -0,0 +1,2 @@ +Schema: + type: string \ No newline at end of file diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/snapshot.txt b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/snapshot.txt new file mode 100644 index 0000000000..d5657fc7e6 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/snapshot.txt @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 +paths: + /users: + get: + parameters: + - $ref: '#/components/parameters/Param' + responses: + '200': + content: + application/json: + schema: {} +components: + schemas: + Schema: + type: string + parameters: + Param: + name: param + in: query + required: true + schema: + $ref: '#/components/schemas/Schema' + +bundling openapi.yaml using configuration for api 'test-api'... +๐Ÿ“ฆ Created a bundle for openapi.yaml at stdout ms. diff --git a/tests/e2e/bundle/bundle.test.ts b/tests/e2e/bundle/bundle.test.ts index 3e9ab9ca03..bf5adc0ace 100644 --- a/tests/e2e/bundle/bundle.test.ts +++ b/tests/e2e/bundle/bundle.test.ts @@ -73,6 +73,17 @@ describe('bundle with option in config: remove-unused-components', () => { ); }); + test.each([ + 'oas3-parameter-ref-to-schema', + 'oas3-parameter-ref-to-schema-with-unused-schema-same-ref', + 'oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref', + ])('%s: should remove unused components', async (type) => { + const testPath = join(__dirname, `bundle-remove-unused-components-from-config/${type}`); + const args = getParams(indexEntryPoint, ['bundle', '--config=redocly.yaml']); + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + test.each(['oas2-without-option', 'oas3-without-option'])( "%s: shouldn't remove unused components", async (type) => { From 3fd15ae1b59b7d63437591526c0dacec74627f1d Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Thu, 9 Apr 2026 18:18:37 +0300 Subject: [PATCH 02/19] fix: type missmatch --- packages/cli/src/__tests__/fixtures/config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/__tests__/fixtures/config.ts b/packages/cli/src/__tests__/fixtures/config.ts index 2611e20238..dadefb5d66 100644 --- a/packages/cli/src/__tests__/fixtures/config.ts +++ b/packages/cli/src/__tests__/fixtures/config.ts @@ -60,5 +60,6 @@ export const configFixture: Config = { getDecoratorSettings: vi.fn(), getUnusedRules: vi.fn(), getRulesForSpecVersion: vi.fn(), + getPostBundleDecoratorsForSpecVersion: vi.fn(), forAlias: vi.fn(() => configFixture), } as Omit as Config; From 2cfefc4478c3aa91db9b438fb53789e6beb6b4b3 Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Thu, 9 Apr 2026 18:31:19 +0300 Subject: [PATCH 03/19] chore: add changeset and remove folder --- .changeset/tame-spoons-show.md | 6 ++++++ .../openapi.yaml | 21 ------------------- .../parameters.yaml | 6 ------ .../schemas.yaml | 4 ---- 4 files changed, 6 insertions(+), 31 deletions(-) create mode 100644 .changeset/tame-spoons-show.md delete mode 100644 parameter-reference-to-schema-ext/openapi.yaml delete mode 100644 parameter-reference-to-schema-ext/parameters.yaml delete mode 100644 parameter-reference-to-schema-ext/schemas.yaml diff --git a/.changeset/tame-spoons-show.md b/.changeset/tame-spoons-show.md new file mode 100644 index 0000000000..aa7e047eeb --- /dev/null +++ b/.changeset/tame-spoons-show.md @@ -0,0 +1,6 @@ +--- +"@redocly/openapi-core": minor +--- + +Moved the `remove-unused-components` decorator to a post-bundle phase so that components that become unused only after `$ref` resolution are correctly removed. +Decorators can now be registered as post-bundle decorators via the `postBundleDecorators` field in the plugin definition. diff --git a/parameter-reference-to-schema-ext/openapi.yaml b/parameter-reference-to-schema-ext/openapi.yaml deleted file mode 100644 index 40fbdece3b..0000000000 --- a/parameter-reference-to-schema-ext/openapi.yaml +++ /dev/null @@ -1,21 +0,0 @@ -openapi: 3.0.0 -info: - title: Sample API - version: 1.0.0 -paths: - /users: - get: - operationId: getUsers - summary: Get a list of users - parameters: - - $ref: parameters.yaml#/Param - responses: - '200': - description: A list of users - content: - application/json: - schema: {} -components: - schemas: - User: - $ref: schemas.yaml#/User diff --git a/parameter-reference-to-schema-ext/parameters.yaml b/parameter-reference-to-schema-ext/parameters.yaml deleted file mode 100644 index 04188a821e..0000000000 --- a/parameter-reference-to-schema-ext/parameters.yaml +++ /dev/null @@ -1,6 +0,0 @@ -Param: - name: param - in: query - required: true - schema: - $ref: schemas.yaml#/Schema diff --git a/parameter-reference-to-schema-ext/schemas.yaml b/parameter-reference-to-schema-ext/schemas.yaml deleted file mode 100644 index 35c1261756..0000000000 --- a/parameter-reference-to-schema-ext/schemas.yaml +++ /dev/null @@ -1,4 +0,0 @@ -/clSchema: - type: string -User: - type: string From dccfbbf520d3e36915eb212396884cb27527ee04 Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Thu, 9 Apr 2026 18:38:35 +0300 Subject: [PATCH 04/19] refactor: getContainingComponentKey for oas2 --- .../oas2/remove-unused-components.ts | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/core/src/decorators/oas2/remove-unused-components.ts b/packages/core/src/decorators/oas2/remove-unused-components.ts index 3c907711bd..c89581b61a 100644 --- a/packages/core/src/decorators/oas2/remove-unused-components.ts +++ b/packages/core/src/decorators/oas2/remove-unused-components.ts @@ -16,23 +16,20 @@ export const RemoveUnusedComponents: Oas2Decorator = () => { >(); function registerComponent(componentType: keyof Oas2Components, name: string): void { - const pointer = `#/${componentType}/${name}`; - components.set(pointer, { - usedIn: components.get(pointer)?.usedIn ?? [], + const key = `${componentType}/${name}`; + components.set(key, { + usedIn: components.get(key)?.usedIn ?? [], componentType, name, }); } - function getComponentPointer(pointer: string): string | undefined { - for (const type of OAS2_COMPONENT_TYPES) { - const prefix = `#/${type}/`; - if (pointer.startsWith(prefix)) { - const name = pointer.slice(prefix.length).split('/')[0]; - if (name) return `#/${type}/${name}`; - } - } - return undefined; + function getContainingComponentKey(pointer: string): string | undefined { + const parts = pointer.replace(/^#\//, '').split('/'); + if (parts.length < 2) return undefined; + const [type, name] = parts; + if (!OAS2_COMPONENT_TYPES.includes(type as keyof Oas2Components)) return undefined; + return `${type}/${name}`; } function removeUnusedComponents( @@ -65,10 +62,10 @@ export const RemoveUnusedComponents: Oas2Decorator = () => { ref: { leave(ref, { location, type }) { if (['Schema', 'Parameter', 'Response', 'SecurityScheme'].includes(type.name)) { - const targetPointer = getComponentPointer(ref.$ref); + const targetPointer = getContainingComponentKey(ref.$ref); if (!targetPointer) return; - const sourcePointer = getComponentPointer(location.pointer); + const sourcePointer = getContainingComponentKey(location.pointer); const registered = components.get(targetPointer); if (registered) { From 449d6bb784b4e6f58003f668a4875a6b5f005ada Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Thu, 9 Apr 2026 18:54:00 +0300 Subject: [PATCH 05/19] feat: add escape pointer --- packages/core/src/decorators/oas2/remove-unused-components.ts | 4 +++- packages/core/src/decorators/oas3/remove-unused-components.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/decorators/oas2/remove-unused-components.ts b/packages/core/src/decorators/oas2/remove-unused-components.ts index c89581b61a..412958ed99 100644 --- a/packages/core/src/decorators/oas2/remove-unused-components.ts +++ b/packages/core/src/decorators/oas2/remove-unused-components.ts @@ -1,3 +1,4 @@ +import { unescapePointerFragment } from '../../ref-utils.js'; import type { Oas2Components, Oas2Definition } from '../../typings/swagger.js'; import { isEmptyObject } from '../../utils/is-empty-object.js'; import type { Oas2Decorator } from '../../visitors.js'; @@ -27,7 +28,8 @@ export const RemoveUnusedComponents: Oas2Decorator = () => { function getContainingComponentKey(pointer: string): string | undefined { const parts = pointer.replace(/^#\//, '').split('/'); if (parts.length < 2) return undefined; - const [type, name] = parts; + const type = unescapePointerFragment(parts[0]); + const name = unescapePointerFragment(parts[1]); if (!OAS2_COMPONENT_TYPES.includes(type as keyof Oas2Components)) return undefined; return `${type}/${name}`; } diff --git a/packages/core/src/decorators/oas3/remove-unused-components.ts b/packages/core/src/decorators/oas3/remove-unused-components.ts index d019eeb78c..4bfe4d7777 100644 --- a/packages/core/src/decorators/oas3/remove-unused-components.ts +++ b/packages/core/src/decorators/oas3/remove-unused-components.ts @@ -1,3 +1,4 @@ +import { unescapePointerFragment } from '../../ref-utils.js'; import type { Oas3Definition, Oas3_1Definition, @@ -20,7 +21,7 @@ function getContainingComponentKey(pointer: string): string | undefined { const rest = pointer.slice(COMPONENTS_PREFIX.length); const parts = rest.split('/'); if (parts.length < 2) return undefined; - return `${parts[0]}/${parts[1]}`; + return `${unescapePointerFragment(parts[0])}/${unescapePointerFragment(parts[1])}`; } export const RemoveUnusedComponents: Oas3Decorator = () => { From f69d84dc359d85ec7bbe3ce4b25136b2770d7af2 Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Thu, 9 Apr 2026 21:14:06 +0300 Subject: [PATCH 06/19] refactor: remove undefined from return --- packages/core/src/decorators/oas3/remove-unused-components.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/decorators/oas3/remove-unused-components.ts b/packages/core/src/decorators/oas3/remove-unused-components.ts index 4bfe4d7777..49497537cc 100644 --- a/packages/core/src/decorators/oas3/remove-unused-components.ts +++ b/packages/core/src/decorators/oas3/remove-unused-components.ts @@ -17,10 +17,10 @@ type AnyOas3ComponentsKey = keyof Oas3Components | keyof Oas3_1Components | keyo const COMPONENTS_PREFIX = '#/components/'; function getContainingComponentKey(pointer: string): string | undefined { - if (!pointer.startsWith(COMPONENTS_PREFIX)) return undefined; + if (!pointer.startsWith(COMPONENTS_PREFIX)) return; const rest = pointer.slice(COMPONENTS_PREFIX.length); const parts = rest.split('/'); - if (parts.length < 2) return undefined; + if (parts.length < 2) return; return `${unescapePointerFragment(parts[0])}/${unescapePointerFragment(parts[1])}`; } From 70f1fa39696c581e2141901c99304c6c368ba97e Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Fri, 10 Apr 2026 12:37:40 +0300 Subject: [PATCH 07/19] refactor: remove runPostBundleDecorators and use directly in bundle --- packages/core/src/bundle/bundle-document.ts | 24 +++++++++----- .../src/bundle/run-post-bundle-decorators.ts | 31 ------------------- packages/core/src/index.ts | 1 - 3 files changed, 16 insertions(+), 40 deletions(-) delete mode 100644 packages/core/src/bundle/run-post-bundle-decorators.ts diff --git a/packages/core/src/bundle/bundle-document.ts b/packages/core/src/bundle/bundle-document.ts index 385548e6ff..86dda75f03 100644 --- a/packages/core/src/bundle/bundle-document.ts +++ b/packages/core/src/bundle/bundle-document.ts @@ -1,5 +1,4 @@ import { makeBundleVisitor } from '../bundle/bundle-visitor.js'; -import { runPostBundleDecorators } from '../bundle/run-post-bundle-decorators.js'; import { type Config } from '../config/config.js'; import { initRules } from '../config/rules.js'; import { type RuleSeverity } from '../config/types.js'; @@ -134,13 +133,22 @@ export async function bundleDocument(opts: { ctx, }); - await runPostBundleDecorators({ - document, - normalizedTypes, - postBundleDecorators, - externalRefResolver, - ctx, - }); + if (postBundleDecorators.length > 0) { + const postBundleRefMap = await resolveDocument({ + rootDocument: document, + rootType: normalizedTypes.Root, + externalRefResolver, + }); + const postBundleVisitors = normalizeVisitors(postBundleDecorators, normalizedTypes); + + walkDocument({ + document, + rootType: normalizedTypes.Root, + normalizedVisitors: postBundleVisitors, + resolvedRefMap: postBundleRefMap, + ctx, + }); + } return { bundle: document, diff --git a/packages/core/src/bundle/run-post-bundle-decorators.ts b/packages/core/src/bundle/run-post-bundle-decorators.ts deleted file mode 100644 index 3ed15310f2..0000000000 --- a/packages/core/src/bundle/run-post-bundle-decorators.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { resolveDocument, type Document, type BaseResolver } from '../resolve.js'; -import { type NormalizedNodeType } from '../types/index.js'; -import { normalizeVisitors } from '../visitors.js'; -import { walkDocument, type WalkContext, type ProblemSeverity } from '../walk.js'; - -export async function runPostBundleDecorators(opts: { - document: Document; - normalizedTypes: Record; - postBundleDecorators: { severity: ProblemSeverity; ruleId: string; visitor: any }[]; - externalRefResolver: BaseResolver; - ctx: WalkContext; -}): Promise { - const { document, normalizedTypes, postBundleDecorators, externalRefResolver, ctx } = opts; - - if (postBundleDecorators.length === 0) return; - - const postBundleRefMap = await resolveDocument({ - rootDocument: document, - rootType: normalizedTypes.Root, - externalRefResolver, - }); - const postBundleVisitors = normalizeVisitors(postBundleDecorators, normalizedTypes); - - walkDocument({ - document, - rootType: normalizedTypes.Root, - normalizedVisitors: postBundleVisitors, - resolvedRefMap: postBundleRefMap, - ctx, - }); -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 30af0153a9..6283dc1e48 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -119,7 +119,6 @@ export { lint, lint as validate, lintDocument, lintFromString, lintConfig } from export { lintEntityFile, lintEntityWithScorecardLevel, lintSchema } from './lint-entity.js'; export { bundle, bundleFromString, type BundleResult } from './bundle/bundle.js'; export { bundleDocument } from './bundle/bundle-document.js'; -export { runPostBundleDecorators } from './bundle/run-post-bundle-decorators.js'; export { mapTypeToComponent } from './bundle/bundle-visitor.js'; export { type Assertions, type Assertion } from './rules/common/assertions/index.js'; export { logger, type LoggerInterface } from './logger.js'; From a3e8b040e828ae82e2f833159b54926d3e27a72c Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Fri, 10 Apr 2026 12:37:55 +0300 Subject: [PATCH 08/19] refactor: use parsePointer and remove undefinder check --- .../oas2/remove-unused-components.ts | 16 ++++++---------- .../oas3/remove-unused-components.ts | 19 ++++++++----------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/packages/core/src/decorators/oas2/remove-unused-components.ts b/packages/core/src/decorators/oas2/remove-unused-components.ts index 412958ed99..e8cc63029a 100644 --- a/packages/core/src/decorators/oas2/remove-unused-components.ts +++ b/packages/core/src/decorators/oas2/remove-unused-components.ts @@ -1,4 +1,4 @@ -import { unescapePointerFragment } from '../../ref-utils.js'; +import { parsePointer } from '../../ref-utils.js'; import type { Oas2Components, Oas2Definition } from '../../typings/swagger.js'; import { isEmptyObject } from '../../utils/is-empty-object.js'; import type { Oas2Decorator } from '../../visitors.js'; @@ -13,7 +13,7 @@ const OAS2_COMPONENT_TYPES: (keyof Oas2Components)[] = [ export const RemoveUnusedComponents: Oas2Decorator = () => { const components = new Map< string, - { usedIn: (string | undefined)[]; componentType?: keyof Oas2Components; name: string } + { usedIn: string[]; componentType?: keyof Oas2Components; name: string } >(); function registerComponent(componentType: keyof Oas2Components, name: string): void { @@ -26,10 +26,8 @@ export const RemoveUnusedComponents: Oas2Decorator = () => { } function getContainingComponentKey(pointer: string): string | undefined { - const parts = pointer.replace(/^#\//, '').split('/'); - if (parts.length < 2) return undefined; - const type = unescapePointerFragment(parts[0]); - const name = unescapePointerFragment(parts[1]); + const [type, name] = parsePointer(pointer.slice(2)); + if (!type || !name) return undefined; if (!OAS2_COMPONENT_TYPES.includes(type as keyof Oas2Components)) return undefined; return `${type}/${name}`; } @@ -41,9 +39,7 @@ export const RemoveUnusedComponents: Oas2Decorator = () => { const countBefore = removedKeys.size; for (const [key, { usedIn, name, componentType }] of components) { - const used = usedIn.some( - (sourceKey) => sourceKey === undefined || !removedKeys.has(sourceKey) - ); + const used = usedIn.some((sourceKey) => !removedKeys.has(sourceKey)); if (!used && componentType) { removedKeys.add(key); @@ -67,7 +63,7 @@ export const RemoveUnusedComponents: Oas2Decorator = () => { const targetPointer = getContainingComponentKey(ref.$ref); if (!targetPointer) return; - const sourcePointer = getContainingComponentKey(location.pointer); + const sourcePointer = getContainingComponentKey(location.pointer) ?? location.pointer; const registered = components.get(targetPointer); if (registered) { diff --git a/packages/core/src/decorators/oas3/remove-unused-components.ts b/packages/core/src/decorators/oas3/remove-unused-components.ts index 49497537cc..bd6d3f795d 100644 --- a/packages/core/src/decorators/oas3/remove-unused-components.ts +++ b/packages/core/src/decorators/oas3/remove-unused-components.ts @@ -1,4 +1,4 @@ -import { unescapePointerFragment } from '../../ref-utils.js'; +import { parsePointer } from '../../ref-utils.js'; import type { Oas3Definition, Oas3_1Definition, @@ -14,21 +14,20 @@ import type { Oas3Decorator } from '../../visitors.js'; type AnyOas3Definition = Oas3Definition | Oas3_1Definition | Oas3_2Definition; type AnyOas3ComponentsKey = keyof Oas3Components | keyof Oas3_1Components | keyof Oas3_2Components; -const COMPONENTS_PREFIX = '#/components/'; +export const COMPONENTS_PREFIX = '#/components/'; function getContainingComponentKey(pointer: string): string | undefined { if (!pointer.startsWith(COMPONENTS_PREFIX)) return; - const rest = pointer.slice(COMPONENTS_PREFIX.length); - const parts = rest.split('/'); - if (parts.length < 2) return; - return `${unescapePointerFragment(parts[0])}/${unescapePointerFragment(parts[1])}`; + const [type, name] = parsePointer(pointer.slice(COMPONENTS_PREFIX.length)); + if (!type || !name) return; + return `${type}/${name}`; } export const RemoveUnusedComponents: Oas3Decorator = () => { const components = new Map< string, { - usedIn: (string | undefined)[]; + usedIn: string[]; componentType?: AnyOas3ComponentsKey; name: string; } @@ -50,9 +49,7 @@ export const RemoveUnusedComponents: Oas3Decorator = () => { const countBefore = removedKeys.size; for (const [key, { usedIn, name, componentType }] of components) { - const used = usedIn.some( - (sourceKey) => sourceKey === undefined || !removedKeys.has(sourceKey) - ); + const used = usedIn.some((sourceKey) => !removedKeys.has(sourceKey)); if ( !used && @@ -92,7 +89,7 @@ export const RemoveUnusedComponents: Oas3Decorator = () => { const targetPointer = getContainingComponentKey(ref.$ref); if (!targetPointer) return; - const sourcePointer = getContainingComponentKey(location.pointer); + const sourcePointer = getContainingComponentKey(location.pointer) ?? location.pointer; const registered = components.get(targetPointer); if (registered) { From d061de24c1a4cd2c606beb17a2de20872aa44fd7 Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Fri, 10 Apr 2026 12:52:19 +0300 Subject: [PATCH 09/19] feat: cover case with recursive ref --- .../oas2/remove-unused-components.ts | 2 +- .../oas3/remove-unused-components.ts | 2 +- .../oas3-recursive-ref/openapi.yaml | 24 +++++++++++++++++++ .../oas3-recursive-ref/redocly.yaml | 6 +++++ .../oas3-recursive-ref/snapshot.txt | 16 +++++++++++++ tests/e2e/bundle/bundle.test.ts | 1 + 6 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/openapi.yaml create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/redocly.yaml create mode 100644 tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/snapshot.txt diff --git a/packages/core/src/decorators/oas2/remove-unused-components.ts b/packages/core/src/decorators/oas2/remove-unused-components.ts index e8cc63029a..f6128d12e1 100644 --- a/packages/core/src/decorators/oas2/remove-unused-components.ts +++ b/packages/core/src/decorators/oas2/remove-unused-components.ts @@ -39,7 +39,7 @@ export const RemoveUnusedComponents: Oas2Decorator = () => { const countBefore = removedKeys.size; for (const [key, { usedIn, name, componentType }] of components) { - const used = usedIn.some((sourceKey) => !removedKeys.has(sourceKey)); + const used = usedIn.some((sourceKey) => sourceKey !== key && !removedKeys.has(sourceKey)); if (!used && componentType) { removedKeys.add(key); diff --git a/packages/core/src/decorators/oas3/remove-unused-components.ts b/packages/core/src/decorators/oas3/remove-unused-components.ts index bd6d3f795d..0b40ed77ab 100644 --- a/packages/core/src/decorators/oas3/remove-unused-components.ts +++ b/packages/core/src/decorators/oas3/remove-unused-components.ts @@ -49,7 +49,7 @@ export const RemoveUnusedComponents: Oas3Decorator = () => { const countBefore = removedKeys.size; for (const [key, { usedIn, name, componentType }] of components) { - const used = usedIn.some((sourceKey) => !removedKeys.has(sourceKey)); + const used = usedIn.some((sourceKey) => sourceKey !== key && !removedKeys.has(sourceKey)); if ( !used && diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/openapi.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/openapi.yaml new file mode 100644 index 0000000000..2fbb722ca2 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/openapi.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 +paths: + /users: + get: + responses: + '200': + content: + application/json: + schema: {} +components: + schemas: + RecursiveRef: + type: object + properties: + prop: + type: array + items: + anyOf: + - $ref: '#/components/schemas/RecursiveRef' + UnusedComponent: + type: string \ No newline at end of file diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/redocly.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/redocly.yaml new file mode 100644 index 0000000000..4af1376a34 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/redocly.yaml @@ -0,0 +1,6 @@ +apis: + test-api: + root: openapi.yaml + +decorators: + remove-unused-components: on \ No newline at end of file diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/snapshot.txt b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/snapshot.txt new file mode 100644 index 0000000000..d6907852f9 --- /dev/null +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/snapshot.txt @@ -0,0 +1,16 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 +paths: + /users: + get: + responses: + '200': + content: + application/json: + schema: {} + +bundling openapi.yaml using configuration for api 'test-api'... +๐Ÿ“ฆ Created a bundle for openapi.yaml at stdout ms. +๐Ÿงน Removed 2 unused components. diff --git a/tests/e2e/bundle/bundle.test.ts b/tests/e2e/bundle/bundle.test.ts index bf5adc0ace..0188c55411 100644 --- a/tests/e2e/bundle/bundle.test.ts +++ b/tests/e2e/bundle/bundle.test.ts @@ -77,6 +77,7 @@ describe('bundle with option in config: remove-unused-components', () => { 'oas3-parameter-ref-to-schema', 'oas3-parameter-ref-to-schema-with-unused-schema-same-ref', 'oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref', + 'oas3-recursive-ref', ])('%s: should remove unused components', async (type) => { const testPath = join(__dirname, `bundle-remove-unused-components-from-config/${type}`); const args = getParams(indexEntryPoint, ['bundle', '--config=redocly.yaml']); From 0bfa67f69ae8db0e0d4c5cfb1ec5689e86370359 Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Fri, 10 Apr 2026 12:56:55 +0300 Subject: [PATCH 10/19] refactor: use parseRef for pointers --- .../core/src/decorators/oas2/remove-unused-components.ts | 4 ++-- .../core/src/decorators/oas3/remove-unused-components.ts | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/core/src/decorators/oas2/remove-unused-components.ts b/packages/core/src/decorators/oas2/remove-unused-components.ts index f6128d12e1..3edcd603c2 100644 --- a/packages/core/src/decorators/oas2/remove-unused-components.ts +++ b/packages/core/src/decorators/oas2/remove-unused-components.ts @@ -1,4 +1,4 @@ -import { parsePointer } from '../../ref-utils.js'; +import { parseRef } from '../../ref-utils.js'; import type { Oas2Components, Oas2Definition } from '../../typings/swagger.js'; import { isEmptyObject } from '../../utils/is-empty-object.js'; import type { Oas2Decorator } from '../../visitors.js'; @@ -26,7 +26,7 @@ export const RemoveUnusedComponents: Oas2Decorator = () => { } function getContainingComponentKey(pointer: string): string | undefined { - const [type, name] = parsePointer(pointer.slice(2)); + const [type, name] = parseRef(pointer).pointer; if (!type || !name) return undefined; if (!OAS2_COMPONENT_TYPES.includes(type as keyof Oas2Components)) return undefined; return `${type}/${name}`; diff --git a/packages/core/src/decorators/oas3/remove-unused-components.ts b/packages/core/src/decorators/oas3/remove-unused-components.ts index 0b40ed77ab..4337db2ff8 100644 --- a/packages/core/src/decorators/oas3/remove-unused-components.ts +++ b/packages/core/src/decorators/oas3/remove-unused-components.ts @@ -1,4 +1,4 @@ -import { parsePointer } from '../../ref-utils.js'; +import { parseRef } from '../../ref-utils.js'; import type { Oas3Definition, Oas3_1Definition, @@ -14,11 +14,9 @@ import type { Oas3Decorator } from '../../visitors.js'; type AnyOas3Definition = Oas3Definition | Oas3_1Definition | Oas3_2Definition; type AnyOas3ComponentsKey = keyof Oas3Components | keyof Oas3_1Components | keyof Oas3_2Components; -export const COMPONENTS_PREFIX = '#/components/'; - function getContainingComponentKey(pointer: string): string | undefined { - if (!pointer.startsWith(COMPONENTS_PREFIX)) return; - const [type, name] = parsePointer(pointer.slice(COMPONENTS_PREFIX.length)); + if (!pointer.startsWith('#/components/')) return; + const [_component, type, name] = parseRef(pointer).pointer; if (!type || !name) return; return `${type}/${name}`; } From 9cc466c8cdb60e88e058d49ffba51caddd9102d2 Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Fri, 10 Apr 2026 12:58:57 +0300 Subject: [PATCH 11/19] refactor: add to oas2 proper key --- packages/core/src/decorators/oas2/remove-unused-components.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/decorators/oas2/remove-unused-components.ts b/packages/core/src/decorators/oas2/remove-unused-components.ts index 3edcd603c2..14f161c3ec 100644 --- a/packages/core/src/decorators/oas2/remove-unused-components.ts +++ b/packages/core/src/decorators/oas2/remove-unused-components.ts @@ -58,7 +58,7 @@ export const RemoveUnusedComponents: Oas2Decorator = () => { return { ref: { - leave(ref, { location, type }) { + leave(ref, { location, type, key }) { if (['Schema', 'Parameter', 'Response', 'SecurityScheme'].includes(type.name)) { const targetPointer = getContainingComponentKey(ref.$ref); if (!targetPointer) return; @@ -71,7 +71,7 @@ export const RemoveUnusedComponents: Oas2Decorator = () => { } else { components.set(targetPointer, { usedIn: [sourcePointer], - name: ref.$ref.split('/').pop()!, + name: key.toString(), }); } } From b4f9dc4720fb07166b4681782aa34ffc0bc514f7 Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Fri, 10 Apr 2026 13:04:22 +0300 Subject: [PATCH 12/19] chore: add line brake to yaml --- .../parameters.yaml | 2 +- .../redocly.yaml | 2 +- .../schemas.yaml | 2 +- .../openapi.yaml | 2 +- .../parameters.yaml | 2 +- .../redocly.yaml | 2 +- .../schemas.yaml | 2 +- .../oas3-parameter-ref-to-schema/openapi.yaml | 2 +- .../oas3-parameter-ref-to-schema/parameters.yaml | 2 +- .../oas3-parameter-ref-to-schema/redocly.yaml | 2 +- .../oas3-parameter-ref-to-schema/schemas.yaml | 2 +- .../oas3-recursive-ref/openapi.yaml | 2 +- .../oas3-recursive-ref/redocly.yaml | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/parameters.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/parameters.yaml index 33e16a2f82..04188a821e 100644 --- a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/parameters.yaml +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/parameters.yaml @@ -3,4 +3,4 @@ Param: in: query required: true schema: - $ref: schemas.yaml#/Schema \ No newline at end of file + $ref: schemas.yaml#/Schema diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/redocly.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/redocly.yaml index 4af1376a34..3c417243f8 100644 --- a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/redocly.yaml +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/redocly.yaml @@ -3,4 +3,4 @@ apis: root: openapi.yaml decorators: - remove-unused-components: on \ No newline at end of file + remove-unused-components: on diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/schemas.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/schemas.yaml index 79b60e8a13..9adc67534d 100644 --- a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/schemas.yaml +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-opposite-ref/schemas.yaml @@ -1,4 +1,4 @@ Schema: type: string User: - type: string \ No newline at end of file + type: string diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/openapi.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/openapi.yaml index 9c0ef83c77..4e31c80daf 100644 --- a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/openapi.yaml +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/openapi.yaml @@ -18,4 +18,4 @@ paths: components: schemas: User: - $ref: schemas.yaml#/Schema \ No newline at end of file + $ref: schemas.yaml#/Schema diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/parameters.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/parameters.yaml index 33e16a2f82..04188a821e 100644 --- a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/parameters.yaml +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/parameters.yaml @@ -3,4 +3,4 @@ Param: in: query required: true schema: - $ref: schemas.yaml#/Schema \ No newline at end of file + $ref: schemas.yaml#/Schema diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/redocly.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/redocly.yaml index 4af1376a34..3c417243f8 100644 --- a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/redocly.yaml +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/redocly.yaml @@ -3,4 +3,4 @@ apis: root: openapi.yaml decorators: - remove-unused-components: on \ No newline at end of file + remove-unused-components: on diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/schemas.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/schemas.yaml index b8456dfcc5..72170c47b6 100644 --- a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/schemas.yaml +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema-with-unused-schema-same-ref/schemas.yaml @@ -1,2 +1,2 @@ Schema: - type: string \ No newline at end of file + type: string diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/openapi.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/openapi.yaml index 6956175035..e7f89495e4 100644 --- a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/openapi.yaml +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/openapi.yaml @@ -11,4 +11,4 @@ paths: '200': content: application/json: - schema: {} \ No newline at end of file + schema: {} diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/parameters.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/parameters.yaml index 33e16a2f82..04188a821e 100644 --- a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/parameters.yaml +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/parameters.yaml @@ -3,4 +3,4 @@ Param: in: query required: true schema: - $ref: schemas.yaml#/Schema \ No newline at end of file + $ref: schemas.yaml#/Schema diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/redocly.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/redocly.yaml index 4af1376a34..3c417243f8 100644 --- a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/redocly.yaml +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/redocly.yaml @@ -3,4 +3,4 @@ apis: root: openapi.yaml decorators: - remove-unused-components: on \ No newline at end of file + remove-unused-components: on diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/schemas.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/schemas.yaml index b8456dfcc5..72170c47b6 100644 --- a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/schemas.yaml +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-parameter-ref-to-schema/schemas.yaml @@ -1,2 +1,2 @@ Schema: - type: string \ No newline at end of file + type: string diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/openapi.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/openapi.yaml index 2fbb722ca2..08c09b4eaa 100644 --- a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/openapi.yaml +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/openapi.yaml @@ -21,4 +21,4 @@ components: anyOf: - $ref: '#/components/schemas/RecursiveRef' UnusedComponent: - type: string \ No newline at end of file + type: string diff --git a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/redocly.yaml b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/redocly.yaml index 4af1376a34..3c417243f8 100644 --- a/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/redocly.yaml +++ b/tests/e2e/bundle/bundle-remove-unused-components-from-config/oas3-recursive-ref/redocly.yaml @@ -3,4 +3,4 @@ apis: root: openapi.yaml decorators: - remove-unused-components: on \ No newline at end of file + remove-unused-components: on From c7c9b29a0a373d565b2e25fda177e05ca3fe394c Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Tue, 14 Apr 2026 10:25:53 +0300 Subject: [PATCH 13/19] feat: remove global postBundleDecorators to local implementation for one decorator --- packages/cli/src/__tests__/fixtures/config.ts | 1 - packages/core/src/bundle/bundle-document.ts | 41 +++++++------- packages/core/src/config/builtIn.ts | 14 +---- packages/core/src/config/config.ts | 54 ------------------- packages/core/src/config/types.ts | 1 - 5 files changed, 22 insertions(+), 89 deletions(-) diff --git a/packages/cli/src/__tests__/fixtures/config.ts b/packages/cli/src/__tests__/fixtures/config.ts index dadefb5d66..2611e20238 100644 --- a/packages/cli/src/__tests__/fixtures/config.ts +++ b/packages/cli/src/__tests__/fixtures/config.ts @@ -60,6 +60,5 @@ export const configFixture: Config = { getDecoratorSettings: vi.fn(), getUnusedRules: vi.fn(), getRulesForSpecVersion: vi.fn(), - getPostBundleDecoratorsForSpecVersion: vi.fn(), forAlias: vi.fn(() => configFixture), } as Omit as Config; diff --git a/packages/core/src/bundle/bundle-document.ts b/packages/core/src/bundle/bundle-document.ts index 86dda75f03..4fd6476dbb 100644 --- a/packages/core/src/bundle/bundle-document.ts +++ b/packages/core/src/bundle/bundle-document.ts @@ -57,10 +57,7 @@ export async function bundleDocument(opts: { const normalizedTypes = normalizeTypes(config.extendTypes(types, specVersion), config); const preprocessors = initRules(rules, config, 'preprocessors', specVersion); - const regularDecorators = initRules(rules, config, 'decorators', specVersion); - - const postBundleRules = config.getPostBundleDecoratorsForSpecVersion(specMajorVersion); - const postBundleDecorators = initRules(postBundleRules, config, 'decorators', specVersion); + const decorators = initRules(rules, config, 'decorators', specVersion); const ctx: BundleContext = { problems: [], @@ -70,20 +67,6 @@ export async function bundleDocument(opts: { visitorsData: {}, }; - if ( - removeUnusedComponents && - !postBundleDecorators.some((d) => d.ruleId === 'remove-unused-components') - ) { - postBundleDecorators.push({ - severity: 'error', - ruleId: 'remove-unused-components', - visitor: - specMajorVersion === 'oas2' - ? RemoveUnusedComponentsOas2({}) - : RemoveUnusedComponentsOas3({}), - }); - } - let resolvedRefMap = await resolveDocument({ rootDocument: document, rootType: normalizedTypes.Root, @@ -120,7 +103,7 @@ export async function bundleDocument(opts: { componentRenamingConflicts, }), }, - ...regularDecorators, + ...decorators.filter((decorator) => decorator.ruleId !== 'remove-unused-components'), ], normalizedTypes ); @@ -133,13 +116,29 @@ export async function bundleDocument(opts: { ctx, }); - if (postBundleDecorators.length > 0) { + const shouldRemoveUnused = + removeUnusedComponents || + config.getDecoratorSettings('remove-unused-components', specVersion).severity !== 'off'; + + if (shouldRemoveUnused) { const postBundleRefMap = await resolveDocument({ rootDocument: document, rootType: normalizedTypes.Root, externalRefResolver, }); - const postBundleVisitors = normalizeVisitors(postBundleDecorators, normalizedTypes); + const postBundleVisitors = normalizeVisitors( + [ + { + severity: 'error', + ruleId: 'remove-unused-components', + visitor: + specMajorVersion === 'oas2' + ? RemoveUnusedComponentsOas2({}) + : RemoveUnusedComponentsOas3({}), + }, + ], + normalizedTypes + ); walkDocument({ document, diff --git a/packages/core/src/config/builtIn.ts b/packages/core/src/config/builtIn.ts index df0fdd84d0..c8a975724b 100644 --- a/packages/core/src/config/builtIn.ts +++ b/packages/core/src/config/builtIn.ts @@ -1,14 +1,8 @@ import { decorators as arazzo1Decorators } from '../decorators/arazzo/index.js'; import { decorators as async2Decorators } from '../decorators/async2/index.js'; import { decorators as async3Decorators } from '../decorators/async3/index.js'; -import { - decorators as oas2Decorators, - postBundleDecorators as oas2PostBundleDecorators, -} from '../decorators/oas2/index.js'; -import { - decorators as oas3Decorators, - postBundleDecorators as oas3PostBundleDecorators, -} from '../decorators/oas3/index.js'; +import { decorators as oas2Decorators } from '../decorators/oas2/index.js'; +import { decorators as oas3Decorators } from '../decorators/oas3/index.js'; import { decorators as openrpc1Decorators } from '../decorators/openrpc/index.js'; import { decorators as overlay1Decorators } from '../decorators/overlay1/index.js'; import { @@ -77,9 +71,5 @@ export const defaultPlugin: Plugin<'built-in'> = { overlay1: overlay1Decorators, openrpc1: openrpc1Decorators, }, - postBundleDecorators: { - oas3: oas3PostBundleDecorators, - oas2: oas2PostBundleDecorators, - }, configs: builtInConfigs, }; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 86800b35d5..7ef6647bbe 100755 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -397,60 +397,6 @@ export class Config { } } - getPostBundleDecoratorsForSpecVersion(version: SpecMajorVersion) { - switch (version) { - case 'oas3': { - const sets: Oas3RuleSet[] = []; - this.plugins.forEach( - (p) => p.postBundleDecorators?.oas3 && sets.push(p.postBundleDecorators.oas3) - ); - return sets; - } - case 'oas2': { - const sets: Oas2RuleSet[] = []; - this.plugins.forEach( - (p) => p.postBundleDecorators?.oas2 && sets.push(p.postBundleDecorators.oas2) - ); - return sets; - } - case 'async2': { - const sets: Async2RuleSet[] = []; - this.plugins.forEach( - (p) => p.postBundleDecorators?.async2 && sets.push(p.postBundleDecorators.async2) - ); - return sets; - } - case 'async3': { - const sets: Async3RuleSet[] = []; - this.plugins.forEach( - (p) => p.postBundleDecorators?.async3 && sets.push(p.postBundleDecorators.async3) - ); - return sets; - } - case 'arazzo1': { - const sets: Arazzo1RuleSet[] = []; - this.plugins.forEach( - (p) => p.postBundleDecorators?.arazzo1 && sets.push(p.postBundleDecorators.arazzo1) - ); - return sets; - } - case 'overlay1': { - const sets: Overlay1RuleSet[] = []; - this.plugins.forEach( - (p) => p.postBundleDecorators?.overlay1 && sets.push(p.postBundleDecorators.overlay1) - ); - return sets; - } - case 'openrpc1': { - const sets: OpenRpc1RuleSet[] = []; - this.plugins.forEach( - (p) => p.postBundleDecorators?.openrpc1 && sets.push(p.postBundleDecorators.openrpc1) - ); - return sets; - } - } - } - skipRules(rules?: string[]) { for (const ruleId of rules || []) { for (const version of specVersions) { diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index f34f865929..d91ece0f99 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -181,7 +181,6 @@ export type Plugin = { rules?: RulesConfig; preprocessors?: PreprocessorsConfig; decorators?: DecoratorsConfig; - postBundleDecorators?: DecoratorsConfig; typeExtension?: TypeExtensionsConfig; assertions?: AssertionsConfig; From e144213321575ffbff805ad0cd134e0ccd613233 Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Tue, 14 Apr 2026 10:26:31 +0300 Subject: [PATCH 14/19] chore: change changeset --- .changeset/tame-spoons-show.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/tame-spoons-show.md b/.changeset/tame-spoons-show.md index aa7e047eeb..ada24128e5 100644 --- a/.changeset/tame-spoons-show.md +++ b/.changeset/tame-spoons-show.md @@ -3,4 +3,3 @@ --- Moved the `remove-unused-components` decorator to a post-bundle phase so that components that become unused only after `$ref` resolution are correctly removed. -Decorators can now be registered as post-bundle decorators via the `postBundleDecorators` field in the plugin definition. From 07c806a0630def30651beb36ff76b06b5deb5d36 Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Tue, 14 Apr 2026 11:09:58 +0300 Subject: [PATCH 15/19] chore: remove postBundleDecorators from configs --- packages/core/src/decorators/oas2/index.ts | 3 --- packages/core/src/decorators/oas3/index.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/packages/core/src/decorators/oas2/index.ts b/packages/core/src/decorators/oas2/index.ts index d12ce261d9..60bf7f36f7 100644 --- a/packages/core/src/decorators/oas2/index.ts +++ b/packages/core/src/decorators/oas2/index.ts @@ -16,8 +16,5 @@ export const decorators = { 'remove-x-internal': RemoveXInternal as Oas2Decorator, 'filter-in': FilterIn as Oas2Decorator, 'filter-out': FilterOut as Oas2Decorator, -}; - -export const postBundleDecorators = { 'remove-unused-components': RemoveUnusedComponents, }; diff --git a/packages/core/src/decorators/oas3/index.ts b/packages/core/src/decorators/oas3/index.ts index d983f8b74c..746915d80a 100644 --- a/packages/core/src/decorators/oas3/index.ts +++ b/packages/core/src/decorators/oas3/index.ts @@ -19,8 +19,5 @@ export const decorators = { 'filter-in': FilterIn as Oas3Decorator, 'filter-out': FilterOut as Oas3Decorator, 'media-type-examples-override': MediaTypeExamplesOverride as Oas3Decorator, -}; - -export const postBundleDecorators = { 'remove-unused-components': RemoveUnusedComponents, }; From 21fd4685c7eb193f7fc6b71f6f0eeb37cb5216d3 Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Tue, 14 Apr 2026 11:25:51 +0300 Subject: [PATCH 16/19] feat: add missing check in oas2 for local pointer --- packages/core/src/decorators/oas2/remove-unused-components.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/decorators/oas2/remove-unused-components.ts b/packages/core/src/decorators/oas2/remove-unused-components.ts index 14f161c3ec..c933076f4f 100644 --- a/packages/core/src/decorators/oas2/remove-unused-components.ts +++ b/packages/core/src/decorators/oas2/remove-unused-components.ts @@ -26,6 +26,7 @@ export const RemoveUnusedComponents: Oas2Decorator = () => { } function getContainingComponentKey(pointer: string): string | undefined { + if (!pointer.startsWith('#/')) return; const [type, name] = parseRef(pointer).pointer; if (!type || !name) return undefined; if (!OAS2_COMPONENT_TYPES.includes(type as keyof Oas2Components)) return undefined; From f86a295d55568fdc72d520a65c735e93701b2a8c Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Tue, 14 Apr 2026 12:09:17 +0300 Subject: [PATCH 17/19] refactor: function and statement naming --- packages/core/src/bundle/bundle-document.ts | 7 +++---- .../src/decorators/oas2/remove-unused-components.ts | 10 +++++----- .../src/decorators/oas3/remove-unused-components.ts | 10 +++++----- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/core/src/bundle/bundle-document.ts b/packages/core/src/bundle/bundle-document.ts index 4fd6476dbb..fb6895ebe7 100644 --- a/packages/core/src/bundle/bundle-document.ts +++ b/packages/core/src/bundle/bundle-document.ts @@ -116,11 +116,10 @@ export async function bundleDocument(opts: { ctx, }); - const shouldRemoveUnused = + if ( removeUnusedComponents || - config.getDecoratorSettings('remove-unused-components', specVersion).severity !== 'off'; - - if (shouldRemoveUnused) { + config.getDecoratorSettings('remove-unused-components', specVersion).severity !== 'off' + ) { const postBundleRefMap = await resolveDocument({ rootDocument: document, rootType: normalizedTypes.Root, diff --git a/packages/core/src/decorators/oas2/remove-unused-components.ts b/packages/core/src/decorators/oas2/remove-unused-components.ts index c933076f4f..38fc07a247 100644 --- a/packages/core/src/decorators/oas2/remove-unused-components.ts +++ b/packages/core/src/decorators/oas2/remove-unused-components.ts @@ -25,7 +25,7 @@ export const RemoveUnusedComponents: Oas2Decorator = () => { }); } - function getContainingComponentKey(pointer: string): string | undefined { + function getComponentKey(pointer: string): string | undefined { if (!pointer.startsWith('#/')) return; const [type, name] = parseRef(pointer).pointer; if (!type || !name) return undefined; @@ -37,7 +37,7 @@ export const RemoveUnusedComponents: Oas2Decorator = () => { root: Oas2Definition, removedKeys: Set = new Set() ): number { - const countBefore = removedKeys.size; + const removedCountBefore = removedKeys.size; for (const [key, { usedIn, name, componentType }] of components) { const used = usedIn.some((sourceKey) => sourceKey !== key && !removedKeys.has(sourceKey)); @@ -52,7 +52,7 @@ export const RemoveUnusedComponents: Oas2Decorator = () => { } } - return removedKeys.size > countBefore + return removedKeys.size > removedCountBefore ? removeUnusedComponents(root, removedKeys) : removedKeys.size; } @@ -61,10 +61,10 @@ export const RemoveUnusedComponents: Oas2Decorator = () => { ref: { leave(ref, { location, type, key }) { if (['Schema', 'Parameter', 'Response', 'SecurityScheme'].includes(type.name)) { - const targetPointer = getContainingComponentKey(ref.$ref); + const targetPointer = getComponentKey(ref.$ref); if (!targetPointer) return; - const sourcePointer = getContainingComponentKey(location.pointer) ?? location.pointer; + const sourcePointer = getComponentKey(location.pointer) ?? location.pointer; const registered = components.get(targetPointer); if (registered) { diff --git a/packages/core/src/decorators/oas3/remove-unused-components.ts b/packages/core/src/decorators/oas3/remove-unused-components.ts index 4337db2ff8..5f5a419279 100644 --- a/packages/core/src/decorators/oas3/remove-unused-components.ts +++ b/packages/core/src/decorators/oas3/remove-unused-components.ts @@ -14,7 +14,7 @@ import type { Oas3Decorator } from '../../visitors.js'; type AnyOas3Definition = Oas3Definition | Oas3_1Definition | Oas3_2Definition; type AnyOas3ComponentsKey = keyof Oas3Components | keyof Oas3_1Components | keyof Oas3_2Components; -function getContainingComponentKey(pointer: string): string | undefined { +function getComponentKey(pointer: string): string | undefined { if (!pointer.startsWith('#/components/')) return; const [_component, type, name] = parseRef(pointer).pointer; if (!type || !name) return; @@ -44,7 +44,7 @@ export const RemoveUnusedComponents: Oas3Decorator = () => { root: AnyOas3Definition, removedKeys: Set = new Set() ): number { - const countBefore = removedKeys.size; + const removedCountBefore = removedKeys.size; for (const [key, { usedIn, name, componentType }] of components) { const used = usedIn.some((sourceKey) => sourceKey !== key && !removedKeys.has(sourceKey)); @@ -65,7 +65,7 @@ export const RemoveUnusedComponents: Oas3Decorator = () => { } } - return removedKeys.size > countBefore + return removedKeys.size > removedCountBefore ? removeUnusedComponents(root, removedKeys) : removedKeys.size; } @@ -84,10 +84,10 @@ export const RemoveUnusedComponents: Oas3Decorator = () => { 'MediaTypesMap', ].includes(type.name) ) { - const targetPointer = getContainingComponentKey(ref.$ref); + const targetPointer = getComponentKey(ref.$ref); if (!targetPointer) return; - const sourcePointer = getContainingComponentKey(location.pointer) ?? location.pointer; + const sourcePointer = getComponentKey(location.pointer) ?? location.pointer; const registered = components.get(targetPointer); if (registered) { From ce1e6787ee4df9aa0db1b8ec16eec8d251591dc9 Mon Sep 17 00:00:00 2001 From: Albina Blazhko Date: Tue, 14 Apr 2026 12:54:43 +0300 Subject: [PATCH 18/19] chore: add cli to changeset --- .changeset/tame-spoons-show.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/tame-spoons-show.md b/.changeset/tame-spoons-show.md index ada24128e5..7be13c1083 100644 --- a/.changeset/tame-spoons-show.md +++ b/.changeset/tame-spoons-show.md @@ -1,5 +1,6 @@ --- "@redocly/openapi-core": minor +"@redocly/cli": minor --- Moved the `remove-unused-components` decorator to a post-bundle phase so that components that become unused only after `$ref` resolution are correctly removed. From cdbc84299c5765936c4358db58fcd77d7dba13e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:10:02 +0200 Subject: [PATCH 19/19] Apply suggestion from @JLekawa --- .changeset/tame-spoons-show.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tame-spoons-show.md b/.changeset/tame-spoons-show.md index 7be13c1083..8a1dc36d07 100644 --- a/.changeset/tame-spoons-show.md +++ b/.changeset/tame-spoons-show.md @@ -3,4 +3,4 @@ "@redocly/cli": minor --- -Moved the `remove-unused-components` decorator to a post-bundle phase so that components that become unused only after `$ref` resolution are correctly removed. +Moved the `remove-unused-components` decorator to the post-bundle phase so that components that become unused only after `$ref` resolution are correctly removed.