Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
366010a
feat: setup the postbundle decorators and refactor remove-unused-comp…
AlbinaBlazhko17 Apr 9, 2026
3fd15ae
fix: type missmatch
AlbinaBlazhko17 Apr 9, 2026
2cfefc4
chore: add changeset and remove folder
AlbinaBlazhko17 Apr 9, 2026
dccfbbf
refactor: getContainingComponentKey for oas2
AlbinaBlazhko17 Apr 9, 2026
449d6bb
feat: add escape pointer
AlbinaBlazhko17 Apr 9, 2026
f69d84d
refactor: remove undefined from return
AlbinaBlazhko17 Apr 9, 2026
e3edba7
Merge branch 'main' into feat/add-post-decorator-phase
AlbinaBlazhko17 Apr 10, 2026
70f1fa3
refactor: remove runPostBundleDecorators and use directly in bundle
AlbinaBlazhko17 Apr 10, 2026
a3e8b04
refactor: use parsePointer and remove undefinder check
AlbinaBlazhko17 Apr 10, 2026
d061de2
feat: cover case with recursive ref
AlbinaBlazhko17 Apr 10, 2026
0bfa67f
refactor: use parseRef for pointers
AlbinaBlazhko17 Apr 10, 2026
9cc466c
refactor: add to oas2 proper key
AlbinaBlazhko17 Apr 10, 2026
b4f9dc4
chore: add line brake to yaml
AlbinaBlazhko17 Apr 10, 2026
c7c9b29
feat: remove global postBundleDecorators to local implementation for …
AlbinaBlazhko17 Apr 14, 2026
e144213
chore: change changeset
AlbinaBlazhko17 Apr 14, 2026
07c806a
chore: remove postBundleDecorators from configs
AlbinaBlazhko17 Apr 14, 2026
21fd468
feat: add missing check in oas2 for local pointer
AlbinaBlazhko17 Apr 14, 2026
3510d4c
Merge branch 'main' into feat/add-post-decorator-phase
AlbinaBlazhko17 Apr 14, 2026
f86a295
refactor: function and statement naming
AlbinaBlazhko17 Apr 14, 2026
ce1e678
chore: add cli to changeset
AlbinaBlazhko17 Apr 14, 2026
cdbc842
Apply suggestion from @JLekawa
JLekawa Apr 14, 2026
83d363a
Merge branch 'main' into feat/add-post-decorator-phase
AlbinaBlazhko17 Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tame-spoons-show.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@redocly/openapi-core": minor
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It also affects how the CLI behaves and I think it would be useful to specify it directly in it's changelog.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, will add it.

---

Moved the `remove-unused-components` decorator to a post-bundle phase so that components that become unused only after `$ref` resolution are correctly removed.
Comment thread
JLekawa marked this conversation as resolved.
Outdated
46 changes: 34 additions & 12 deletions packages/core/src/bundle/bundle-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,6 @@ export async function bundleDocument(opts: {
visitorsData: {},
};

if (removeUnusedComponents && !decorators.some((d) => d.ruleId === 'remove-unused-components')) {
decorators.push({
severity: 'error',
ruleId: 'remove-unused-components',
visitor:
specMajorVersion === 'oas2'
? RemoveUnusedComponentsOas2({})
: RemoveUnusedComponentsOas3({}),
});
}

let resolvedRefMap = await resolveDocument({
rootDocument: document,
rootType: normalizedTypes.Root,
Expand Down Expand Up @@ -114,7 +103,7 @@ export async function bundleDocument(opts: {
componentRenamingConflicts,
}),
},
...decorators,
...decorators.filter((decorator) => decorator.ruleId !== 'remove-unused-components'),
],
normalizedTypes
);
Expand All @@ -127,6 +116,39 @@ export async function bundleDocument(opts: {
ctx,
});

const shouldRemoveUnused =
removeUnusedComponents ||
config.getDecoratorSettings('remove-unused-components', specVersion).severity !== 'off';
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

if (shouldRemoveUnused) {
const postBundleRefMap = await resolveDocument({
rootDocument: document,
rootType: normalizedTypes.Root,
externalRefResolver,
});
const postBundleVisitors = normalizeVisitors(
[
{
severity: 'error',
ruleId: 'remove-unused-components',
visitor:
specMajorVersion === 'oas2'
? RemoveUnusedComponentsOas2({})
: RemoveUnusedComponentsOas3({}),
},
],
normalizedTypes
);

walkDocument({
document,
rootType: normalizedTypes.Root,
normalizedVisitors: postBundleVisitors,
resolvedRefMap: postBundleRefMap,
ctx,
});
}

return {
bundle: document,
problems: ctx.problems.map((problem) => config.addProblemToIgnore(problem)),
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/decorators/oas2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ 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
'remove-unused-components': RemoveUnusedComponents,
Comment thread
tatomyr marked this conversation as resolved.
};
102 changes: 51 additions & 51 deletions packages/core/src/decorators/oas2/remove-unused-components.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,77 @@
import type { Location } 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';

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[]; 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 key = `${componentType}/${name}`;
components.set(key, {
usedIn: components.get(key)?.usedIn ?? [],
componentType,
name,
});
}

function removeUnusedComponents(root: Oas2Definition, removedPaths: string[]): number {
const removedLengthStart = removedPaths.length;
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;
return `${type}/${name}`;
}
Comment thread
cursor[bot] marked this conversation as resolved.

function removeUnusedComponents(
root: Oas2Definition,
removedKeys: Set<string> = new Set()
): number {
const countBefore = removedKeys.size;

for (const [key, { usedIn, name, componentType }] of components) {
const used = usedIn.some((sourceKey) => sourceKey !== key && !removedKeys.has(sourceKey));

for (const [path, { 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] === '/')
)
);
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, key }) {
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 = getContainingComponentKey(ref.$ref);
if (!targetPointer) return;

const registered = components.get(pointer);
const sourcePointer = getContainingComponentKey(location.pointer) ?? 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(),
});
}
Expand All @@ -81,29 +81,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());
},
},
};
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/decorators/oas3/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ 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
'remove-unused-components': RemoveUnusedComponents,
Comment thread
cursor[bot] marked this conversation as resolved.
};
Loading
Loading