Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
6 changes: 6 additions & 0 deletions .changeset/tame-spoons-show.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@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
Decorators can now be registered as post-bundle decorators via the `postBundleDecorators` field in the plugin definition.
1 change: 1 addition & 0 deletions packages/cli/src/__tests__/fixtures/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Config, '_usedRules' | '_usedVersions'> as Config;
23 changes: 19 additions & 4 deletions packages/core/src/bundle/bundle-document.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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: [],
Expand All @@ -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:
Expand Down Expand Up @@ -114,7 +121,7 @@ export async function bundleDocument(opts: {
componentRenamingConflicts,
}),
},
...decorators,
...regularDecorators,
],
normalizedTypes
);
Expand All @@ -127,6 +134,14 @@ export async function bundleDocument(opts: {
ctx,
});

await runPostBundleDecorators({
Comment thread
tatomyr marked this conversation as resolved.
Outdated
document,
normalizedTypes,
postBundleDecorators,
externalRefResolver,
ctx,
});

return {
bundle: document,
problems: ctx.problems.map((problem) => config.addProblemToIgnore(problem)),
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/bundle/run-post-bundle-decorators.ts
Original file line number Diff line number Diff line change
@@ -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<string, NormalizedNodeType>;
postBundleDecorators: { severity: ProblemSeverity; ruleId: string; visitor: any }[];
externalRefResolver: BaseResolver;
ctx: WalkContext;
}): Promise<void> {
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,
});
}
14 changes: 12 additions & 2 deletions packages/core/src/config/builtIn.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -71,5 +77,9 @@ export const defaultPlugin: Plugin<'built-in'> = {
overlay1: overlay1Decorators,
openrpc1: openrpc1Decorators,
},
postBundleDecorators: {
oas3: oas3PostBundleDecorators,
oas2: oas2PostBundleDecorators,
},
configs: builtInConfigs,
};
54 changes: 54 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export type Plugin<T = undefined> = {
rules?: RulesConfig<T>;
preprocessors?: PreprocessorsConfig;
decorators?: DecoratorsConfig;
postBundleDecorators?: DecoratorsConfig;
typeExtension?: TypeExtensionsConfig;
assertions?: AssertionsConfig;

Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/decorators/oas2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
tatomyr marked this conversation as resolved.
};
101 changes: 51 additions & 50 deletions packages/core/src/decorators/oas2/remove-unused-components.ts
Original file line number Diff line number Diff line change
@@ -1,78 +1,79 @@
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 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 {
const parts = pointer.replace(/^#\//, '').split('/');
Comment thread
tatomyr marked this conversation as resolved.
Outdated
if (parts.length < 2) return undefined;
const [type, name] = parts;
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 [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 = 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],
name: key.toString(),
components.set(targetPointer, {
usedIn: [sourcePointer],
name: ref.$ref.split('/').pop()!,
});
}
}
Expand All @@ -81,29 +82,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
5 changes: 4 additions & 1 deletion packages/core/src/decorators/oas3/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
cursor[bot] marked this conversation as resolved.
};
Loading
Loading