Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
974233d
feat: add support for vendor extensions in stats reporting
n0rahh Aug 5, 2026
9a79e40
chore: update stats documentation to include Vendor Extensions metrics
n0rahh Aug 5, 2026
b957cfe
chore: update snapshots
n0rahh Aug 5, 2026
304374f
tests: add e2e tests
n0rahh Aug 5, 2026
5af764e
tests: add unit tests
n0rahh Aug 5, 2026
d8f062b
chore: update snapshot to include Vendor Extensions in stats
n0rahh Aug 5, 2026
3f2b9f8
feat: add Vendor Extensions metric to stats command
n0rahh Aug 5, 2026
2efddb1
feat: enhance StatsSpecExtensions to correctly handle extensions next…
n0rahh Aug 5, 2026
c2439e9
Update .changeset/seven-waves-create.md
JLekawa Aug 5, 2026
05fbcdf
feat: add support for counting map-typed extensions in StatsSpecExten…
n0rahh Aug 5, 2026
15e669c
feat: add masking for sensitive values in StatsSpecExtensions
n0rahh Aug 5, 2026
c99afa0
feat: add authorization key to be masked in StatsSpecExtensions tests
n0rahh Aug 5, 2026
3bb9922
chore: refactor after cr
n0rahh Aug 6, 2026
d327074
Merge branch 'main' into feat/stats-for-vendor-extensions
n0rahh Aug 6, 2026
13e252e
feat: add support for collecting sibling extensions in stats processing
n0rahh Aug 6, 2026
d1430fd
chore: update docs examples
n0rahh Aug 6, 2026
a7053a7
feat: enhance stats processing for vendor extensions with detailed co…
n0rahh Aug 7, 2026
a68162f
feat: implement spec extension dispatch and enhance stats collection …
n0rahh Aug 7, 2026
561ede2
feat: add tests and fixtures for vendor extension counts in AsyncAPI …
n0rahh Aug 7, 2026
06d4e23
feat: update stats processing to correctly count 'x-query' extensions…
n0rahh Aug 7, 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/seven-waves-create.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@redocly/openapi-core': minor
'@redocly/cli': minor
---

Added a Vendor Extensions metric to the `stats` command that reports how many distinct `x-` extensions a description file uses and how often each one occurs.
41 changes: 41 additions & 0 deletions docs/@v2/commands/stats.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ The metrics reported depend on the type of API description:
- Webhooks
- Operations
- Tags
- Vendor Extensions

**AsyncAPI 2.x and AsyncAPI 3.x**

Expand All @@ -31,6 +32,9 @@ The metrics reported depend on the type of API description:
- Channels
- Operations
- Tags
- Vendor Extensions

For **Vendor Extensions**, the count is the number of distinct `x-` extensions used, and each extension is listed with how many times it occurs.

If you're interested in the technical details, the statistics are calculated using the counting logic from the `StatsVisitor` module.

Expand Down Expand Up @@ -123,6 +127,11 @@ Document: museum.yaml stats:
🎣 Webhooks: 0
👷 Operations: 8
🔖 Tags: 3
🧩 Vendor Extensions: 4
- x-badges: 1
- x-codeSamples: 2
- x-internal: 2
- x-metadata: 1

museum.yaml: stats processed in 4ms
</pre>
Expand All @@ -139,6 +148,9 @@ Document: asyncapi.yaml stats:
📡 Channels: 1
👷 Operations: 1
🔖 Tags: 2
🧩 Vendor Extensions: 2
- x-internal: 1
- x-metadata: 1

asyncapi.yaml: stats processed in 4ms
</pre>
Expand Down Expand Up @@ -187,6 +199,16 @@ The following is an example JSON output for an OpenAPI description:
"tags": {
"metric": "🔖 Tags",
"total": 3
},
"xExtensions": {
"metric": "🧩 Vendor Extensions",
"total": 4,
"counts": {
"x-badges": 1,
"x-codeSamples": 2,
"x-internal": 2,
"x-metadata": 1
}
}
}
</pre>
Expand Down Expand Up @@ -214,6 +236,15 @@ The following is an example source output for an OpenAPI description:
| 🎣 Webhooks | 0 |
| 👷 Operations | 8 |
| 🔖 Tags | 3 |
| 🧩 Vendor Extensions | 4 |

#### 🧩 Vendor Extensions
| Extension | Count |
| --- | --- |
| x-badges | 1 |
| x-codeSamples | 2 |
| x-internal | 2 |
| x-metadata | 1 |

</pre>

Expand All @@ -230,6 +261,16 @@ Here's the rendered example source output:
| 🎣 Webhooks | 0 |
| 👷 Operations | 8 |
| 🔖 Tags | 3 |
| 🧩 Vendor Extensions | 4 |

**🧩 Vendor Extensions**

| Extension | Count |
| ------------- | ----- |
| x-badges | 1 |
| x-codeSamples | 2 |
| x-internal | 2 |
| x-metadata | 1 |

For AsyncAPI descriptions, the table includes a `📡 Channels` row instead of the `🔗 Links`, `🔀 Path Items`, and `🎣 Webhooks` rows.

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/stats/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
normalizeVisitors,
walkDocument,
bundle,
ensureSpecExtensionDispatch,
type WalkContext,
type OutputFormat,
} from '@redocly/openapi-core';
Expand All @@ -30,6 +31,7 @@ export async function handleStats({ argv, config, collectSpecData }: CommandArgs
collectSpecData?.(document);
const specVersion = detectSpec(document.parsed);
const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config);
ensureSpecExtensionDispatch(types);

const { statsVisitor, statsAccumulator } = resolveStatsVisitorAndAccumulator(specVersion);

Expand Down
12 changes: 7 additions & 5 deletions packages/cli/src/commands/stats/print-stats/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {
export function printStatsJson(statsAccumulator: OASStatsAccumulator | AsyncAPIStatsAccumulator) {
const json: any = {};
for (const key of Object.keys(statsAccumulator)) {
const stat = statsAccumulator[key as keyof typeof statsAccumulator];
json[key] = {
metric: stat.metric,
total: stat.total,
};
const { metric, total, details } = statsAccumulator[key as keyof typeof statsAccumulator];
json[key] = { metric, total };
if (details) {
json[key].counts = Object.fromEntries(
Object.entries(details).map(([name, { count }]) => [name, count])
);
}
}

logger.output(JSON.stringify(json, null, 2));
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/commands/stats/print-stats/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,19 @@ export function printStatsMarkdown(
statsAccumulator: OASStatsAccumulator | AsyncAPIStatsAccumulator
) {
let output = '| Feature | Count |\n| --- | --- |\n';
const breakdowns: string[] = [];
for (const key of Object.keys(statsAccumulator)) {
const stat = statsAccumulator[key as keyof typeof statsAccumulator];
output += '| ' + stat.metric + ' | ' + stat.total + ' |\n';
const details = Object.entries(stat.details || {});
if (details.length) {
breakdowns.push(
`\n#### ${stat.metric}\n| Extension | Count |\n| --- | --- |\n` +
details.map(([name, { count }]) => `| ${name} | ${count} |`).join('\n') +
'\n'
);
}
}

logger.output(output);
logger.output(output + breakdowns.join(''));
}
5 changes: 4 additions & 1 deletion packages/cli/src/commands/stats/print-stats/stylish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ export function printStatsStylish(
) {
for (const node in statsAccumulator) {
const stat = statsAccumulator[node as keyof typeof statsAccumulator];
const { metric, total, color } = stat;
const { metric, total, color, details } = stat;
const colorFn = colors[color as keyof typeof colors] as (text: string) => string;
logger.output(colorFn(`${metric}: ${total} \n`));
for (const [name, { count }] of Object.entries(details || {})) {
logger.output(colorFn(` - ${name}: ${count} \n`));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function resolveStatsVisitorAndAccumulator(specVersion: SpecVersion) {
webhooks: { metric: '🎣 Webhooks', total: 0, color: 'green' },
operations: { metric: '👷 Operations', total: 0, color: 'yellow' },
tags: { metric: '🔖 Tags', total: 0, color: 'white', items: new Set() },
xExtensions: { metric: '🧩 Vendor Extensions', total: 0, color: 'cyan' },
};
const statsAccumulatorAsync: AsyncAPIStatsAccumulator = {
refs: { metric: '🚗 References', total: 0, color: 'red', items: new Set() },
Expand All @@ -29,6 +30,7 @@ export function resolveStatsVisitorAndAccumulator(specVersion: SpecVersion) {
channels: { metric: '📡 Channels', total: 0, color: 'green' },
operations: { metric: '👷 Operations', total: 0, color: 'yellow' },
tags: { metric: '🔖 Tags', total: 0, color: 'white', items: new Set() },
xExtensions: { metric: '🧩 Vendor Extensions', total: 0, color: 'cyan' },
};

let statsVisitor, statsAccumulator;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export { ConfigTypes, createConfigTypes } from './types/redocly-yaml.js';
export { createEntityTypes } from './types/entity.js';
export { normalizeTypes, type NormalizedNodeType, type NodeType } from './types/index.js';
export { StatsOAS, StatsAsync2, StatsAsync3 } from './rules/other/stats.js';
export { ensureSpecExtensionDispatch } from './utils/spec-extensions.js';
export {
loadConfig,
loadIgnoreConfig,
Expand Down Expand Up @@ -142,4 +143,5 @@ export type {
OASStatsAccumulator,
AsyncAPIStatsAccumulator,
StatsName,
SpecVendorExtensionsAccumulator,
} from './typings/common.js';
71 changes: 60 additions & 11 deletions packages/core/src/rules/other/stats.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { OASStatsAccumulator, AsyncAPIStatsAccumulator } from '../../typings/common.js';
import type {
OASStatsAccumulator,
AsyncAPIStatsAccumulator,
SpecVendorExtensionsAccumulator,
StatsAccumulator,
} from '../../typings/common.js';
import type {
Oas3Link,
Oas3Operation,
Expand All @@ -8,9 +13,34 @@ import type {
OasRef,
} from '../../typings/openapi.js';
import type { Oas2Parameter } from '../../typings/swagger.js';
import { collectSpecExtension } from '../../utils/spec-extensions.js';
import type { UserContext } from '../../walk.js';

function finalizeStats(
statsAccumulator: StatsAccumulator,
extensions: SpecVendorExtensionsAccumulator
) {
for (const row of Object.values(statsAccumulator)) {
if (row.items) {
row.total = row.items.size;
}
}
const extensionNames = Object.keys(extensions).sort();
statsAccumulator.xExtensions.total = extensionNames.length;
statsAccumulator.xExtensions.details = Object.fromEntries(
extensionNames.map((name) => [name, extensions[name]])
);
}

export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => {
const extensions: SpecVendorExtensionsAccumulator = {};

return {
SpecExtension: {
enter(node: unknown, ctx: UserContext) {
collectSpecExtension(extensions, ctx.key.toString(), node);
},
},
ExternalDocs: {
leave() {
statsAccumulator.externalDocs.total++;
Expand All @@ -32,6 +62,11 @@ export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => {
},
},
WebhooksMap: {
enter(node: unknown, ctx: UserContext) {
if (ctx.key === 'x-webhooks') {
collectSpecExtension(extensions, 'x-webhooks', node);
}
},
Operation: {
leave(operation: Oas3Operation) {
statsAccumulator.webhooks.total++;
Expand All @@ -43,6 +78,13 @@ export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => {
},
},
},
Operation: {
enter(operation: Oas3Operation, ctx: UserContext) {
if (ctx.key === 'x-query') {
collectSpecExtension(extensions, 'x-query', operation);
}
},
},
Paths: {
PathItem: {
leave() {
Expand Down Expand Up @@ -74,17 +116,21 @@ export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => {
},
Root: {
leave() {
statsAccumulator.parameters.total = statsAccumulator.parameters.items!.size;
statsAccumulator.refs.total = statsAccumulator.refs.items!.size;
statsAccumulator.links.total = statsAccumulator.links.items!.size;
statsAccumulator.tags.total = statsAccumulator.tags.items!.size;
finalizeStats(statsAccumulator, extensions);
},
},
};
};

export const StatsAsync2 = (statsAccumulator: AsyncAPIStatsAccumulator) => {
const extensions: SpecVendorExtensionsAccumulator = {};

return {
SpecExtension: {
enter(node: unknown, ctx: UserContext) {
collectSpecExtension(extensions, ctx.key.toString(), node);
},
},
ExternalDocs: {
leave() {
statsAccumulator.externalDocs.total++;
Expand Down Expand Up @@ -133,16 +179,21 @@ export const StatsAsync2 = (statsAccumulator: AsyncAPIStatsAccumulator) => {
},
Root: {
leave() {
statsAccumulator.parameters.total = statsAccumulator.parameters.items!.size;
statsAccumulator.refs.total = statsAccumulator.refs.items!.size;
statsAccumulator.tags.total = statsAccumulator.tags.items!.size;
finalizeStats(statsAccumulator, extensions);
},
},
};
};

export const StatsAsync3 = (statsAccumulator: AsyncAPIStatsAccumulator) => {
const extensions: SpecVendorExtensionsAccumulator = {};

return {
SpecExtension: {
enter(node: unknown, ctx: UserContext) {
collectSpecExtension(extensions, ctx.key.toString(), node);
},
},
ExternalDocs: {
leave() {
statsAccumulator.externalDocs.total++;
Expand Down Expand Up @@ -193,9 +244,7 @@ export const StatsAsync3 = (statsAccumulator: AsyncAPIStatsAccumulator) => {
},
Root: {
leave() {
statsAccumulator.parameters.total = statsAccumulator.parameters.items!.size;
statsAccumulator.refs.total = statsAccumulator.refs.items!.size;
statsAccumulator.tags.total = statsAccumulator.tags.items!.size;
finalizeStats(statsAccumulator, extensions);
},
},
};
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/typings/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface StatsRow {
total: number;
color: 'red' | 'yellow' | 'green' | 'white' | 'magenta' | 'cyan';
items?: Set<string>;
details?: SpecVendorExtensionsAccumulator;
}

export type OASStatsName =
Expand All @@ -14,7 +15,8 @@ export type OASStatsName =
| 'links'
| 'schemas'
| 'webhooks'
| 'parameters';
| 'parameters'
| 'xExtensions';

export type AsyncAPIStatsName =
| 'operations'
Expand All @@ -23,9 +25,14 @@ export type AsyncAPIStatsName =
| 'externalDocs'
| 'channels'
| 'schemas'
| 'parameters';
| 'parameters'
| 'xExtensions';

export type StatsName = OASStatsName | AsyncAPIStatsName;
export type OASStatsAccumulator = Record<OASStatsName, StatsRow>;
export type AsyncAPIStatsAccumulator = Record<AsyncAPIStatsName, StatsRow>;
export type StatsAccumulator = OASStatsAccumulator | AsyncAPIStatsAccumulator;

// Per `x-` extension: usage count, and a bounded sample of property names → property values.
export type VendorExtension = { count: number; props: Record<string, Set<string>> };
export type SpecVendorExtensionsAccumulator = Record<string, VendorExtension>;
Loading
Loading