From 974233d4dfa1a849c565a39ef0b9f2909940a640 Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 5 Aug 2026 13:25:12 +0200 Subject: [PATCH 01/19] feat: add support for vendor extensions in stats reporting --- packages/cli/src/commands/stats/index.ts | 9 +- .../src/commands/stats/print-stats/json.ts | 1 + .../commands/stats/print-stats/markdown.ts | 11 ++- .../src/commands/stats/print-stats/stylish.ts | 5 +- .../stats/visitor-and-accumulator-resolver.ts | 2 + packages/core/src/index.ts | 2 + .../core/src/rules/other/spec-extensions.ts | 83 +++++++++++++++++++ packages/core/src/typings/common.ts | 11 ++- 8 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/rules/other/spec-extensions.ts diff --git a/packages/cli/src/commands/stats/index.ts b/packages/cli/src/commands/stats/index.ts index 071a828574..3997f7f655 100644 --- a/packages/cli/src/commands/stats/index.ts +++ b/packages/cli/src/commands/stats/index.ts @@ -7,8 +7,11 @@ import { normalizeVisitors, walkDocument, bundle, + StatsSpecExtensions, + applySpecExtensionsStats, type WalkContext, type OutputFormat, + type SpecVendorExtensionsAccumulator, } from '@redocly/openapi-core'; import { performance } from 'perf_hooks'; @@ -47,12 +50,14 @@ export async function handleStats({ argv, config, collectSpecData }: CommandArgs externalRefResolver, }); + const extensionsAccumulator: SpecVendorExtensionsAccumulator = {}; + const normalizedStatsVisitor = normalizeVisitors( [ { severity: 'warn', ruleId: 'stats', - visitor: statsVisitor, + visitor: { ...statsVisitor, ...StatsSpecExtensions(extensionsAccumulator) }, }, ], types @@ -66,5 +71,7 @@ export async function handleStats({ argv, config, collectSpecData }: CommandArgs ctx, }); + applySpecExtensionsStats(extensionsAccumulator, statsAccumulator.xExtensions); + printStats(statsAccumulator, path, startedAt, argv.format); } diff --git a/packages/cli/src/commands/stats/print-stats/json.ts b/packages/cli/src/commands/stats/print-stats/json.ts index 98772fa22f..8431afa46d 100644 --- a/packages/cli/src/commands/stats/print-stats/json.ts +++ b/packages/cli/src/commands/stats/print-stats/json.ts @@ -11,6 +11,7 @@ export function printStatsJson(statsAccumulator: OASStatsAccumulator | AsyncAPIS json[key] = { metric: stat.metric, total: stat.total, + ...(stat.counts && { counts: stat.counts }), }; } diff --git a/packages/cli/src/commands/stats/print-stats/markdown.ts b/packages/cli/src/commands/stats/print-stats/markdown.ts index a3158ac2ee..6e3ba0293a 100644 --- a/packages/cli/src/commands/stats/print-stats/markdown.ts +++ b/packages/cli/src/commands/stats/print-stats/markdown.ts @@ -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 counts = Object.entries(stat.counts || {}); + if (counts.length) { + breakdowns.push( + `\n#### ${stat.metric}\n| Extension | Count |\n| --- | --- |\n` + + counts.map(([name, count]) => `| ${name} | ${count} |`).join('\n') + + '\n' + ); + } } - logger.output(output); + logger.output(output + breakdowns.join('')); } diff --git a/packages/cli/src/commands/stats/print-stats/stylish.ts b/packages/cli/src/commands/stats/print-stats/stylish.ts index 936550c7b2..06d64dbdb6 100644 --- a/packages/cli/src/commands/stats/print-stats/stylish.ts +++ b/packages/cli/src/commands/stats/print-stats/stylish.ts @@ -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, counts } = 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(counts || {})) { + logger.output(colorFn(` - ${name}: ${count} \n`)); + } } } diff --git a/packages/cli/src/commands/stats/visitor-and-accumulator-resolver.ts b/packages/cli/src/commands/stats/visitor-and-accumulator-resolver.ts index 08fd732b58..9fa0a93532 100644 --- a/packages/cli/src/commands/stats/visitor-and-accumulator-resolver.ts +++ b/packages/cli/src/commands/stats/visitor-and-accumulator-resolver.ts @@ -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', counts: {} }, }; const statsAccumulatorAsync: AsyncAPIStatsAccumulator = { refs: { metric: '🚗 References', total: 0, color: 'red', items: new Set() }, @@ -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', counts: {} }, }; let statsVisitor, statsAccumulator; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f1793d8cb5..dd5135dd21 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { StatsSpecExtensions, applySpecExtensionsStats } from './rules/other/spec-extensions.js'; export { loadConfig, loadIgnoreConfig, @@ -142,4 +143,5 @@ export type { OASStatsAccumulator, AsyncAPIStatsAccumulator, StatsName, + SpecVendorExtensionsAccumulator, } from './typings/common.js'; diff --git a/packages/core/src/rules/other/spec-extensions.ts b/packages/core/src/rules/other/spec-extensions.ts new file mode 100644 index 0000000000..741dee110b --- /dev/null +++ b/packages/core/src/rules/other/spec-extensions.ts @@ -0,0 +1,83 @@ +import type { StatsRow, SpecVendorExtensionsAccumulator } from '../../typings/common.js'; +import { isPlainObject } from '../../utils/is-plain-object.js'; +import type { UserContext } from '../../walk.js'; + +const EXTENSION_PREFIX = 'x-'; + +// Strings longer than this become a `` marker, so no code / payload / prose leaves the box. +const MAX_VALUE_LENGTH = 40; +// Both caps bound cardinality for map-like extensions with client-defined keys (x-metadata, x-examples). +const MAX_VALUES_PER_PROP = 20; +const MAX_PROPS_PER_EXTENSION = 20; + +const VALUE_KEY = '$value'; // holds a scalar extension's own value, e.g. `x-hideReplay: true` +const TRUNCATED = ''; + +export const StatsSpecExtensions = (accumulator: SpecVendorExtensionsAccumulator) => { + return { + any: { + enter(node: unknown, ctx: UserContext) { + if (ctx.type.name === 'SpecExtension') return; + if (!isPlainObject(node)) return; + + for (const [key, value] of Object.entries(node)) { + if (!key.startsWith(EXTENSION_PREFIX)) continue; + recordExtension(accumulator, key, value); + } + }, + }, + }; +}; + +function recordExtension( + accumulator: SpecVendorExtensionsAccumulator, + key: string, + value: unknown +) { + const entry = (accumulator[key] ??= { count: 0, props: {} }); + entry.count++; + for (const [prop, propValue] of getExtensionProps(value)) { + addSample(entry.props, prop, describe(propValue)); + } +} + +function getExtensionProps(value: unknown): Array<[string, unknown]> { + if (isPlainObject(value)) return Object.entries(value); + if (Array.isArray(value)) { + return value.flatMap((item) => (isPlainObject(item) ? Object.entries(item) : [])); + } + return [[VALUE_KEY, value]]; +} + +function describe(value: unknown): string { + if (value === null) return ''; + if (typeof value === 'boolean' || typeof value === 'number') return String(value); + if (typeof value === 'string') { + return value.length <= MAX_VALUE_LENGTH ? value : ``; + } + if (Array.isArray(value)) return ``; + if (isPlainObject(value)) return '$ref' in value ? '' : ''; + return ''; +} + +function addSample(props: Record>, prop: string, value: string) { + const isNewProp = !(prop in props); + if (isNewProp && Object.keys(props).length >= MAX_PROPS_PER_EXTENSION) { + prop = TRUNCATED; // too many distinct props: fold the rest under one marker + } + addBounded((props[prop] ??= new Set()), value); +} + +function addBounded(set: Set, value: string) { + if (set.has(value) || set.has(TRUNCATED)) return; + set.add(set.size >= MAX_VALUES_PER_PROP ? TRUNCATED : value); +} + +export function applySpecExtensionsStats( + accumulator: SpecVendorExtensionsAccumulator, + statsRow: StatsRow +) { + const names = Object.keys(accumulator).sort(); + statsRow.total = names.length; + statsRow.counts = Object.fromEntries(names.map((name) => [name, accumulator[name].count])); +} diff --git a/packages/core/src/typings/common.ts b/packages/core/src/typings/common.ts index 294747bfe7..13206cd303 100644 --- a/packages/core/src/typings/common.ts +++ b/packages/core/src/typings/common.ts @@ -3,6 +3,7 @@ export interface StatsRow { total: number; color: 'red' | 'yellow' | 'green' | 'white' | 'magenta' | 'cyan'; items?: Set; + counts?: Record; } export type OASStatsName = @@ -14,7 +15,8 @@ export type OASStatsName = | 'links' | 'schemas' | 'webhooks' - | 'parameters'; + | 'parameters' + | 'xExtensions'; export type AsyncAPIStatsName = | 'operations' @@ -23,9 +25,14 @@ export type AsyncAPIStatsName = | 'externalDocs' | 'channels' | 'schemas' - | 'parameters'; + | 'parameters' + | 'xExtensions'; export type StatsName = OASStatsName | AsyncAPIStatsName; export type OASStatsAccumulator = Record; export type AsyncAPIStatsAccumulator = Record; 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> }; +export type SpecVendorExtensionsAccumulator = Record; From 9a79e40d99d36e4da18fa726cc1a4b716843452c Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 5 Aug 2026 13:25:35 +0200 Subject: [PATCH 02/19] chore: update stats documentation to include Vendor Extensions metrics --- docs/@v2/commands/stats.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/@v2/commands/stats.md b/docs/@v2/commands/stats.md index 5243a4b536..9344b71685 100644 --- a/docs/@v2/commands/stats.md +++ b/docs/@v2/commands/stats.md @@ -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** @@ -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. From b957cfeced2a03ff8d57bc9d5452f76b24f4fada Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 5 Aug 2026 14:02:01 +0200 Subject: [PATCH 03/19] chore: update snapshots --- tests/e2e/stats/stats-async2-json/snapshot.txt | 5 +++++ tests/e2e/stats/stats-async2-stylish/snapshot.txt | 1 + tests/e2e/stats/stats-async3-stylish/snapshot.txt | 1 + tests/e2e/stats/stats-json/snapshot.txt | 5 +++++ tests/e2e/stats/stats-markdown/snapshot.txt | 1 + tests/e2e/stats/stats-stylish/snapshot.txt | 1 + 6 files changed, 14 insertions(+) diff --git a/tests/e2e/stats/stats-async2-json/snapshot.txt b/tests/e2e/stats/stats-async2-json/snapshot.txt index 0a82e4236d..b67d06620b 100644 --- a/tests/e2e/stats/stats-async2-json/snapshot.txt +++ b/tests/e2e/stats/stats-async2-json/snapshot.txt @@ -26,6 +26,11 @@ "tags": { "metric": "🔖 Tags", "total": 2 + }, + "xExtensions": { + "metric": "🧩 Vendor Extensions", + "total": 0, + "counts": {} } } Document: async.yaml stats: diff --git a/tests/e2e/stats/stats-async2-stylish/snapshot.txt b/tests/e2e/stats/stats-async2-stylish/snapshot.txt index 215b1e80dd..77d9d37399 100644 --- a/tests/e2e/stats/stats-async2-stylish/snapshot.txt +++ b/tests/e2e/stats/stats-async2-stylish/snapshot.txt @@ -5,6 +5,7 @@ 📡 Channels: 1 👷 Operations: 1 🔖 Tags: 2 +🧩 Vendor Extensions: 0 Document: async.yaml stats: diff --git a/tests/e2e/stats/stats-async3-stylish/snapshot.txt b/tests/e2e/stats/stats-async3-stylish/snapshot.txt index 2a254b6490..dd4ff058cc 100644 --- a/tests/e2e/stats/stats-async3-stylish/snapshot.txt +++ b/tests/e2e/stats/stats-async3-stylish/snapshot.txt @@ -5,6 +5,7 @@ 📡 Channels: 1 👷 Operations: 1 🔖 Tags: 2 +🧩 Vendor Extensions: 0 Document: asyncapi3.yaml stats: diff --git a/tests/e2e/stats/stats-json/snapshot.txt b/tests/e2e/stats/stats-json/snapshot.txt index 40cec1731c..3996cbe444 100644 --- a/tests/e2e/stats/stats-json/snapshot.txt +++ b/tests/e2e/stats/stats-json/snapshot.txt @@ -34,6 +34,11 @@ "tags": { "metric": "🔖 Tags", "total": 3 + }, + "xExtensions": { + "metric": "🧩 Vendor Extensions", + "total": 0, + "counts": {} } } Document: museum.yaml stats: diff --git a/tests/e2e/stats/stats-markdown/snapshot.txt b/tests/e2e/stats/stats-markdown/snapshot.txt index a901e9bb08..77c3627a0f 100644 --- a/tests/e2e/stats/stats-markdown/snapshot.txt +++ b/tests/e2e/stats/stats-markdown/snapshot.txt @@ -9,6 +9,7 @@ | 🎣 Webhooks | 0 | | 👷 Operations | 8 | | 🔖 Tags | 3 | +| 🧩 Vendor Extensions | 0 | Document: museum.yaml stats: diff --git a/tests/e2e/stats/stats-stylish/snapshot.txt b/tests/e2e/stats/stats-stylish/snapshot.txt index f9acf6fe0d..91dac033ab 100644 --- a/tests/e2e/stats/stats-stylish/snapshot.txt +++ b/tests/e2e/stats/stats-stylish/snapshot.txt @@ -7,6 +7,7 @@ 🎣 Webhooks: 0 👷 Operations: 8 🔖 Tags: 3 +🧩 Vendor Extensions: 0 Document: museum.yaml stats: From 304374fd4a063122a59e4c17de115cee42f226e5 Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 5 Aug 2026 14:14:56 +0200 Subject: [PATCH 04/19] tests: add e2e tests --- tests/e2e/stats/stats-extensions/openapi.yaml | 46 ++++++++++++++++ .../stats/stats-extensions/snapshot-json.txt | 54 +++++++++++++++++++ .../stats-extensions/snapshot-markdown.txt | 27 ++++++++++ .../stats-extensions/snapshot-stylish.txt | 21 ++++++++ tests/e2e/stats/stats.test.ts | 23 ++++++++ 5 files changed, 171 insertions(+) create mode 100644 tests/e2e/stats/stats-extensions/openapi.yaml create mode 100644 tests/e2e/stats/stats-extensions/snapshot-json.txt create mode 100644 tests/e2e/stats/stats-extensions/snapshot-markdown.txt create mode 100644 tests/e2e/stats/stats-extensions/snapshot-stylish.txt diff --git a/tests/e2e/stats/stats-extensions/openapi.yaml b/tests/e2e/stats/stats-extensions/openapi.yaml new file mode 100644 index 0000000000..15623543b1 --- /dev/null +++ b/tests/e2e/stats/stats-extensions/openapi.yaml @@ -0,0 +1,46 @@ +openapi: 3.1.0 +info: + title: Vendor extensions fixture + version: '1.0' + x-metadata: + department: Platform + team: Docs +paths: + /a: + get: + operationId: a + x-codeSamples: + - lang: curl + source: curl https://example.com/a + x-badges: + - name: Beta + color: purple + - name: New + color: green + position: before + x-internal: true + parameters: + - $ref: '#/components/parameters/Shared' + responses: + '200': + description: ok + /b: + get: + operationId: b + x-internal: true + x-codeSamples: + - lang: python + source: print("hi") + parameters: + - $ref: '#/components/parameters/Shared' + responses: + '200': + description: ok +components: + parameters: + Shared: + name: p + in: query + x-hideReplay: true + schema: + type: string diff --git a/tests/e2e/stats/stats-extensions/snapshot-json.txt b/tests/e2e/stats/stats-extensions/snapshot-json.txt new file mode 100644 index 0000000000..f8ea2cbf44 --- /dev/null +++ b/tests/e2e/stats/stats-extensions/snapshot-json.txt @@ -0,0 +1,54 @@ +{ + "refs": { + "metric": "🚗 References", + "total": 1 + }, + "externalDocs": { + "metric": "📦 External Documents", + "total": 0 + }, + "schemas": { + "metric": "📈 Schemas", + "total": 0 + }, + "parameters": { + "metric": "👉 Parameters", + "total": 1 + }, + "links": { + "metric": "🔗 Links", + "total": 0 + }, + "pathItems": { + "metric": "🔀 Path Items", + "total": 2 + }, + "webhooks": { + "metric": "🎣 Webhooks", + "total": 0 + }, + "operations": { + "metric": "👷 Operations", + "total": 2 + }, + "tags": { + "metric": "🔖 Tags", + "total": 0 + }, + "xExtensions": { + "metric": "🧩 Vendor Extensions", + "total": 5, + "counts": { + "x-badges": 1, + "x-codeSamples": 2, + "x-hideReplay": 1, + "x-internal": 2, + "x-metadata": 1 + } + } +} +Document: openapi.yaml stats: + + +openapi.yaml: stats processed in ms + diff --git a/tests/e2e/stats/stats-extensions/snapshot-markdown.txt b/tests/e2e/stats/stats-extensions/snapshot-markdown.txt new file mode 100644 index 0000000000..93a54f276c --- /dev/null +++ b/tests/e2e/stats/stats-extensions/snapshot-markdown.txt @@ -0,0 +1,27 @@ +| Feature | Count | +| --- | --- | +| 🚗 References | 1 | +| 📦 External Documents | 0 | +| 📈 Schemas | 0 | +| 👉 Parameters | 1 | +| 🔗 Links | 0 | +| 🔀 Path Items | 2 | +| 🎣 Webhooks | 0 | +| 👷 Operations | 2 | +| 🔖 Tags | 0 | +| 🧩 Vendor Extensions | 5 | + +#### 🧩 Vendor Extensions +| Extension | Count | +| --- | --- | +| x-badges | 1 | +| x-codeSamples | 2 | +| x-hideReplay | 1 | +| x-internal | 2 | +| x-metadata | 1 | + +Document: openapi.yaml stats: + + +openapi.yaml: stats processed in ms + diff --git a/tests/e2e/stats/stats-extensions/snapshot-stylish.txt b/tests/e2e/stats/stats-extensions/snapshot-stylish.txt new file mode 100644 index 0000000000..35bca1bf84 --- /dev/null +++ b/tests/e2e/stats/stats-extensions/snapshot-stylish.txt @@ -0,0 +1,21 @@ +🚗 References: 1 +📦 External Documents: 0 +📈 Schemas: 0 +👉 Parameters: 1 +🔗 Links: 0 +🔀 Path Items: 2 +🎣 Webhooks: 0 +👷 Operations: 2 +🔖 Tags: 0 +🧩 Vendor Extensions: 5 + - x-badges: 1 + - x-codeSamples: 2 + - x-hideReplay: 1 + - x-internal: 2 + - x-metadata: 1 + +Document: openapi.yaml stats: + + +openapi.yaml: stats processed in ms + diff --git a/tests/e2e/stats/stats.test.ts b/tests/e2e/stats/stats.test.ts index 2ae7473f06..f347f9f661 100644 --- a/tests/e2e/stats/stats.test.ts +++ b/tests/e2e/stats/stats.test.ts @@ -50,4 +50,27 @@ describe('stats', () => { const result = getCommandOutput(args, { testPath }); await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); }); + + test('stats should report vendor extension counts (JSON format)', async () => { + const testPath = join(folderPath, 'stats-extensions'); + const args = getParams(indexEntryPoint, ['stats', 'openapi.yaml', '--format=json']); + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot-json.txt')); + }); + + test('stats should report vendor extension counts (stylish format)', async () => { + const testPath = join(folderPath, 'stats-extensions'); + const args = getParams(indexEntryPoint, ['stats', 'openapi.yaml']); + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot-stylish.txt')); + }); + + test('stats should report vendor extension counts (Markdown format)', async () => { + const testPath = join(folderPath, 'stats-extensions'); + const args = getParams(indexEntryPoint, ['stats', 'openapi.yaml', '--format=markdown']); + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(testPath, 'snapshot-markdown.txt') + ); + }); }); From 5af764e62b3b241f93c87d889c331ee3e0c6b2de Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 5 Aug 2026 14:29:01 +0200 Subject: [PATCH 05/19] tests: add unit tests --- .../other/__tests__/spec-extensions.test.ts | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 packages/core/src/rules/other/__tests__/spec-extensions.test.ts diff --git a/packages/core/src/rules/other/__tests__/spec-extensions.test.ts b/packages/core/src/rules/other/__tests__/spec-extensions.test.ts new file mode 100644 index 0000000000..2ec7aa3954 --- /dev/null +++ b/packages/core/src/rules/other/__tests__/spec-extensions.test.ts @@ -0,0 +1,319 @@ +import { outdent } from 'outdent'; + +import { parseYamlToDocument } from '../../../../__tests__/utils.js'; +import { detectSpec } from '../../../detect-spec.js'; +import { getTypes } from '../../../oas-types.js'; +import { BaseResolver, resolveDocument } from '../../../resolve.js'; +import { normalizeTypes } from '../../../types/index.js'; +import type { SpecVendorExtensionsAccumulator, StatsRow } from '../../../typings/common.js'; +import { normalizeVisitors } from '../../../visitors.js'; +import { walkDocument } from '../../../walk.js'; +import { StatsSpecExtensions, applySpecExtensionsStats } from '../spec-extensions.js'; + +async function collect(yaml: string): Promise { + const document = parseYamlToDocument(yaml, ''); + const specVersion = detectSpec(document.parsed); + const types = normalizeTypes(getTypes(specVersion)); + const accumulator: SpecVendorExtensionsAccumulator = {}; + + const visitors = normalizeVisitors( + [{ severity: 'warn', ruleId: 'test', visitor: StatsSpecExtensions(accumulator) }], + types + ); + const resolvedRefMap = await resolveDocument({ + rootDocument: document, + rootType: types.Root, + externalRefResolver: new BaseResolver(), + }); + walkDocument({ + rootType: types.Root, + normalizedVisitors: visitors, + resolvedRefMap, + document, + ctx: { problems: [], specVersion, visitorsData: {} }, + }); + return accumulator; +} + +const props = (acc: SpecVendorExtensionsAccumulator, name: string, prop: string) => + [...(acc[name]?.props[prop] ?? [])].sort(); + +describe('StatsSpecExtensions', () => { + it('should count every x- key, including extensions that have a declared type in core', async () => { + const acc = await collect(outdent` + openapi: 3.1.0 + info: + title: t + version: '1' + paths: + /a: + get: + operationId: a + x-codeSamples: + - lang: curl + source: curl https://example.com + x-badges: + - name: Beta + color: purple + responses: + '200': + description: ok + `); + + expect(acc['x-codeSamples']?.count).toBe(1); + expect(acc['x-badges']?.count).toBe(1); + }); + + it('should not dedupe repeated scalar values across different nodes', async () => { + const acc = await collect(outdent` + openapi: 3.1.0 + info: + title: t + version: '1' + paths: + /a: + get: + operationId: a + x-internal: true + responses: + '200': + description: ok + /b: + get: + operationId: b + x-internal: true + responses: + '200': + description: ok + `); + + expect(acc['x-internal']?.count).toBe(2); + }); + + it('should count an extension on a $ref-shared node once', async () => { + const acc = await collect(outdent` + openapi: 3.1.0 + info: + title: t + version: '1' + paths: + /a: + get: + operationId: a + parameters: + - $ref: '#/components/parameters/Shared' + responses: + '200': + description: ok + /b: + get: + operationId: b + parameters: + - $ref: '#/components/parameters/Shared' + responses: + '200': + description: ok + components: + parameters: + Shared: + name: p + in: query + x-hideReplay: true + schema: + type: string + `); + + expect(acc['x-hideReplay']?.count).toBe(1); + }); + + it('should not descend into an extension value (no props-of-props)', async () => { + const acc = await collect(outdent` + openapi: 3.1.0 + info: + title: t + version: '1' + x-outer: + x-inner: 1 + `); + + expect(Object.keys(acc)).toEqual(['x-outer']); + expect(props(acc, 'x-outer', 'x-inner')).toEqual(['1']); + }); + + describe('value collection (describe)', () => { + it('should keep short scalars but replace long strings with a length marker', async () => { + const long = 'x'.repeat(80); + const acc = await collect(outdent` + openapi: 3.1.0 + info: + title: t + version: '1' + x-short: hello + x-long: ${long} + `); + + expect(props(acc, 'x-short', '$value')).toEqual(['hello']); + expect(props(acc, 'x-long', '$value')).toEqual(['']); + }); + + it('should mark a $ref value as and an object/array as its type', async () => { + const acc = await collect(outdent` + openapi: 3.1.0 + info: + title: t + version: '1' + paths: + /a: + get: + operationId: a + x-codeSamples: + - lang: curl + source: + $ref: '#/x' + responses: + '200': + description: ok + `); + + expect(props(acc, 'x-codeSamples', 'source')).toEqual(['']); + expect(props(acc, 'x-codeSamples', 'lang')).toEqual(['curl']); + }); + + it('should collect the extension value under $value when it has no own props', async () => { + const acc = await collect(outdent` + openapi: 3.1.0 + info: + title: t + version: '1' + x-flag: true + `); + + expect(props(acc, 'x-flag', '$value')).toEqual(['true']); + }); + }); + + describe('cross-spec (AsyncAPI)', () => { + it('should collect extensions across AsyncAPI 2.x nodes', async () => { + const acc = await collect(outdent` + asyncapi: 2.6.0 + info: + title: t + version: '1' + x-info-ext: true + channels: + user/signedup: + x-badges: + - name: Beta + color: purple + subscribe: + x-op-ext: true + message: + x-msg-ext: a + payload: + type: object + `); + + expect(acc['x-info-ext']?.count).toBe(1); + expect(acc['x-badges']?.count).toBe(1); + expect(acc['x-op-ext']?.count).toBe(1); + expect(acc['x-msg-ext']?.count).toBe(1); + }); + + it('should collect extensions across AsyncAPI 3.x nodes', async () => { + const acc = await collect(outdent` + asyncapi: 3.0.0 + info: + title: t + version: '1' + channels: + userSignedup: + x-channel-ext: 1 + address: user/signedup + messages: + m: + x-msg-ext: a + payload: + type: object + operations: + onSignup: + x-op-ext: true + action: receive + channel: + $ref: '#/channels/userSignedup' + `); + + expect(acc['x-channel-ext']?.count).toBe(1); + expect(acc['x-msg-ext']?.count).toBe(1); + expect(acc['x-op-ext']?.count).toBe(1); + }); + }); + + describe('bounding (caps)', () => { + it('should cap distinct values per prop at 20 and mark the overflow as ', async () => { + const badges = Array.from({ length: 25 }, (_, i) => ` - color: c${i}`).join('\n'); + const acc = await collect(outdent` + openapi: 3.1.0 + info: + title: t + version: '1' + paths: + /a: + get: + operationId: a + x-badges: + ${badges} + responses: + '200': + description: ok + `); + + const values = acc['x-badges'].props.color; + expect(values.size).toBe(21); + expect(values.has('')).toBe(true); + expect(values.has('c0')).toBe(true); + }); + + it('should cap distinct props per extension at 20 and fold the rest under ', async () => { + const keys = Array.from({ length: 25 }, (_, i) => ` k${i}: v${i}`).join('\n'); + const acc = await collect(outdent` + openapi: 3.1.0 + info: + title: t + version: '1' + x-metadata: + ${keys} + `); + + const propNames = Object.keys(acc['x-metadata'].props); + expect(propNames).toHaveLength(21); + expect(propNames).toContain(''); + }); + }); + + describe('applySpecExtensionsStats', () => { + it('should set total to the distinct extension count and counts per extension', () => { + const acc: SpecVendorExtensionsAccumulator = { + 'x-badges': { count: 3, props: {} }, + 'x-internal': { count: 5, props: {} }, + }; + const row: StatsRow = { metric: 'Vendor Extensions', total: 0, color: 'cyan' }; + + applySpecExtensionsStats(acc, row); + + expect(row.total).toBe(2); + expect(row.counts).toEqual({ 'x-badges': 3, 'x-internal': 5 }); + }); + + it('should sort extension names for a stable output', () => { + const acc: SpecVendorExtensionsAccumulator = { + 'x-zeta': { count: 1, props: {} }, + 'x-alpha': { count: 1, props: {} }, + }; + const row: StatsRow = { metric: 'Vendor Extensions', total: 0, color: 'cyan' }; + + applySpecExtensionsStats(acc, row); + + expect(Object.keys(row.counts!)).toEqual(['x-alpha', 'x-zeta']); + }); + }); +}); From d8f062b684d47809f670222f7470526f27b36226 Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 5 Aug 2026 14:33:51 +0200 Subject: [PATCH 06/19] chore: update snapshot to include Vendor Extensions in stats --- .../miscellaneous/resolve-refs-in-preprocessors/snapshot_3.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e/miscellaneous/resolve-refs-in-preprocessors/snapshot_3.txt b/tests/e2e/miscellaneous/resolve-refs-in-preprocessors/snapshot_3.txt index 3c2bbb7512..457d8f8c5d 100644 --- a/tests/e2e/miscellaneous/resolve-refs-in-preprocessors/snapshot_3.txt +++ b/tests/e2e/miscellaneous/resolve-refs-in-preprocessors/snapshot_3.txt @@ -7,6 +7,7 @@ 🎣 Webhooks: 0 👷 Operations: 2 🔖 Tags: 0 +🧩 Vendor Extensions: 0 Document: openapi.yaml stats: From 3f2b9f8b7f898e296402013f3e83a27161d94593 Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 5 Aug 2026 14:36:57 +0200 Subject: [PATCH 07/19] feat: add Vendor Extensions metric to stats command --- .changeset/seven-waves-create.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/seven-waves-create.md diff --git a/.changeset/seven-waves-create.md b/.changeset/seven-waves-create.md new file mode 100644 index 0000000000..46965128f7 --- /dev/null +++ b/.changeset/seven-waves-create.md @@ -0,0 +1,6 @@ +--- +'@redocly/openapi-core': minor +'@redocly/cli': minor +--- + +Add a Vendor Extensions metric to the `stats` command that reports how many distinct `x-` extensions a spec uses and how often each one occurs. From 2efddb1bb329f08afebdfa78fb9a6bd396975d29 Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 5 Aug 2026 16:08:02 +0200 Subject: [PATCH 08/19] feat: enhance StatsSpecExtensions to correctly handle extensions next to $ref and ignore map keys starting with x- --- .../other/__tests__/spec-extensions.test.ts | 56 +++++++++++++++++++ .../core/src/rules/other/spec-extensions.ts | 19 +++++-- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/core/src/rules/other/__tests__/spec-extensions.test.ts b/packages/core/src/rules/other/__tests__/spec-extensions.test.ts index 2ec7aa3954..226e385d91 100644 --- a/packages/core/src/rules/other/__tests__/spec-extensions.test.ts +++ b/packages/core/src/rules/other/__tests__/spec-extensions.test.ts @@ -140,6 +140,62 @@ describe('StatsSpecExtensions', () => { expect(props(acc, 'x-outer', 'x-inner')).toEqual(['1']); }); + it('should not count map keys (schema/component names) that start with x-', async () => { + const acc = await collect(outdent` + openapi: 3.1.0 + info: + title: t + version: '1' + paths: + /a: + get: + operationId: a + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + components: + schemas: + x-MySchema: + type: string + Pet: + type: object + properties: + x-trace-id: + type: string + `); + + // `x-MySchema` (component name) and `x-trace-id` (property name) are map keys, not extensions + expect(acc['x-MySchema']).toBeUndefined(); + expect(acc['x-trace-id']).toBeUndefined(); + }); + + it('should count an extension written next to a $ref', async () => { + const acc = await collect(outdent` + openapi: 3.1.0 + info: + title: t + version: '1' + paths: + /a: + get: + operationId: a + responses: + '200': + $ref: '#/components/responses/Shared' + x-sibling-ext: true + components: + responses: + Shared: + description: ok + `); + + expect(acc['x-sibling-ext']?.count).toBe(1); + }); + describe('value collection (describe)', () => { it('should keep short scalars but replace long strings with a length marker', async () => { const long = 'x'.repeat(80); diff --git a/packages/core/src/rules/other/spec-extensions.ts b/packages/core/src/rules/other/spec-extensions.ts index 741dee110b..6d100d1ebf 100644 --- a/packages/core/src/rules/other/spec-extensions.ts +++ b/packages/core/src/rules/other/spec-extensions.ts @@ -1,3 +1,4 @@ +import { isRef } from '../../ref-utils.js'; import type { StatsRow, SpecVendorExtensionsAccumulator } from '../../typings/common.js'; import { isPlainObject } from '../../utils/is-plain-object.js'; import type { UserContext } from '../../walk.js'; @@ -17,18 +18,24 @@ export const StatsSpecExtensions = (accumulator: SpecVendorExtensionsAccumulator return { any: { enter(node: unknown, ctx: UserContext) { - if (ctx.type.name === 'SpecExtension') return; - if (!isPlainObject(node)) return; + if (Object.keys(ctx.type.properties).length === 0) return; - for (const [key, value] of Object.entries(node)) { - if (!key.startsWith(EXTENSION_PREFIX)) continue; - recordExtension(accumulator, key, value); - } + recordExtensions(accumulator, node); + // Extensions written next to a $ref sit on the raw node, not the resolved target. + if (isRef(ctx.rawNode)) recordExtensions(accumulator, ctx.rawNode); }, }, }; }; +function recordExtensions(accumulator: SpecVendorExtensionsAccumulator, node: unknown) { + if (!isPlainObject(node)) return; + for (const [key, value] of Object.entries(node)) { + if (!key.startsWith(EXTENSION_PREFIX)) continue; + recordExtension(accumulator, key, value); + } +} + function recordExtension( accumulator: SpecVendorExtensionsAccumulator, key: string, From c2439e97fb248568308f9cf72e35255bf601d2ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:22:02 +0200 Subject: [PATCH 09/19] Update .changeset/seven-waves-create.md --- .changeset/seven-waves-create.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/seven-waves-create.md b/.changeset/seven-waves-create.md index 46965128f7..407ffe170c 100644 --- a/.changeset/seven-waves-create.md +++ b/.changeset/seven-waves-create.md @@ -3,4 +3,4 @@ '@redocly/cli': minor --- -Add a Vendor Extensions metric to the `stats` command that reports how many distinct `x-` extensions a spec uses and how often each one occurs. +Added a Vendor Extensions metric to the `stats` command that reports how many distinct `x-` extensions a descrtption file uses and how often each one occurs. From 05fbcdf45df7cdc52f56998654f76ec3390e4a33 Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 5 Aug 2026 16:32:22 +0200 Subject: [PATCH 10/19] feat: add support for counting map-typed extensions in StatsSpecExtensions --- .../other/__tests__/spec-extensions.test.ts | 19 ++++++++++++++ .../core/src/rules/other/spec-extensions.ts | 25 ++++++++++++++++--- tests/e2e/stats/stats-extensions/openapi.yaml | 1 + .../stats/stats-extensions/snapshot-json.txt | 5 ++-- .../stats-extensions/snapshot-markdown.txt | 3 ++- .../stats-extensions/snapshot-stylish.txt | 3 ++- 6 files changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/core/src/rules/other/__tests__/spec-extensions.test.ts b/packages/core/src/rules/other/__tests__/spec-extensions.test.ts index 226e385d91..2e8d6477d8 100644 --- a/packages/core/src/rules/other/__tests__/spec-extensions.test.ts +++ b/packages/core/src/rules/other/__tests__/spec-extensions.test.ts @@ -140,6 +140,25 @@ describe('StatsSpecExtensions', () => { expect(props(acc, 'x-outer', 'x-inner')).toEqual(['1']); }); + it('should count an extension on a map-typed node (Paths)', async () => { + const acc = await collect(outdent` + openapi: 3.1.0 + info: + title: t + version: '1' + paths: + x-paths-ext: true + /a: + get: + operationId: a + responses: + '200': + description: ok + `); + + expect(acc['x-paths-ext']?.count).toBe(1); + }); + it('should not count map keys (schema/component names) that start with x-', async () => { const acc = await collect(outdent` openapi: 3.1.0 diff --git a/packages/core/src/rules/other/spec-extensions.ts b/packages/core/src/rules/other/spec-extensions.ts index 6d100d1ebf..d9344d0eb2 100644 --- a/packages/core/src/rules/other/spec-extensions.ts +++ b/packages/core/src/rules/other/spec-extensions.ts @@ -1,5 +1,7 @@ import { isRef } from '../../ref-utils.js'; +import { isNamedType, SpecExtension, type NormalizedNodeType } from '../../types/index.js'; import type { StatsRow, SpecVendorExtensionsAccumulator } from '../../typings/common.js'; +import { getOwn } from '../../utils/get-own.js'; import { isPlainObject } from '../../utils/is-plain-object.js'; import type { UserContext } from '../../walk.js'; @@ -18,24 +20,39 @@ export const StatsSpecExtensions = (accumulator: SpecVendorExtensionsAccumulator return { any: { enter(node: unknown, ctx: UserContext) { - if (Object.keys(ctx.type.properties).length === 0) return; + if (ctx.type === SpecExtension) return; - recordExtensions(accumulator, node); + recordExtensions(accumulator, node, ctx.type); // Extensions written next to a $ref sit on the raw node, not the resolved target. - if (isRef(ctx.rawNode)) recordExtensions(accumulator, ctx.rawNode); + if (isRef(ctx.rawNode)) recordExtensions(accumulator, ctx.rawNode, ctx.type); }, }, }; }; -function recordExtensions(accumulator: SpecVendorExtensionsAccumulator, node: unknown) { +function recordExtensions( + accumulator: SpecVendorExtensionsAccumulator, + node: unknown, + type: NormalizedNodeType +) { if (!isPlainObject(node)) return; for (const [key, value] of Object.entries(node)) { if (!key.startsWith(EXTENSION_PREFIX)) continue; + if (isMapEntryKey(type, key, value)) continue; recordExtension(accumulator, key, value); } } +// An x- key is not an extension when the type resolves it to a named map entry (a schema name, a channel address). +function isMapEntryKey(type: NormalizedNodeType, key: string, value: unknown): boolean { + if (getOwn(type.properties, key) !== undefined) return false; + const entryType = + typeof type.additionalProperties === 'function' + ? type.additionalProperties(value, key) + : type.additionalProperties; + return isNamedType(entryType); +} + function recordExtension( accumulator: SpecVendorExtensionsAccumulator, key: string, diff --git a/tests/e2e/stats/stats-extensions/openapi.yaml b/tests/e2e/stats/stats-extensions/openapi.yaml index 15623543b1..d42dcb5198 100644 --- a/tests/e2e/stats/stats-extensions/openapi.yaml +++ b/tests/e2e/stats/stats-extensions/openapi.yaml @@ -6,6 +6,7 @@ info: department: Platform team: Docs paths: + x-paths-ext: true /a: get: operationId: a diff --git a/tests/e2e/stats/stats-extensions/snapshot-json.txt b/tests/e2e/stats/stats-extensions/snapshot-json.txt index f8ea2cbf44..cec19513f3 100644 --- a/tests/e2e/stats/stats-extensions/snapshot-json.txt +++ b/tests/e2e/stats/stats-extensions/snapshot-json.txt @@ -37,13 +37,14 @@ }, "xExtensions": { "metric": "🧩 Vendor Extensions", - "total": 5, + "total": 6, "counts": { "x-badges": 1, "x-codeSamples": 2, "x-hideReplay": 1, "x-internal": 2, - "x-metadata": 1 + "x-metadata": 1, + "x-paths-ext": 1 } } } diff --git a/tests/e2e/stats/stats-extensions/snapshot-markdown.txt b/tests/e2e/stats/stats-extensions/snapshot-markdown.txt index 93a54f276c..2bcc70a386 100644 --- a/tests/e2e/stats/stats-extensions/snapshot-markdown.txt +++ b/tests/e2e/stats/stats-extensions/snapshot-markdown.txt @@ -9,7 +9,7 @@ | 🎣 Webhooks | 0 | | 👷 Operations | 2 | | 🔖 Tags | 0 | -| 🧩 Vendor Extensions | 5 | +| 🧩 Vendor Extensions | 6 | #### 🧩 Vendor Extensions | Extension | Count | @@ -19,6 +19,7 @@ | x-hideReplay | 1 | | x-internal | 2 | | x-metadata | 1 | +| x-paths-ext | 1 | Document: openapi.yaml stats: diff --git a/tests/e2e/stats/stats-extensions/snapshot-stylish.txt b/tests/e2e/stats/stats-extensions/snapshot-stylish.txt index 35bca1bf84..22cbd701fa 100644 --- a/tests/e2e/stats/stats-extensions/snapshot-stylish.txt +++ b/tests/e2e/stats/stats-extensions/snapshot-stylish.txt @@ -7,12 +7,13 @@ 🎣 Webhooks: 0 👷 Operations: 2 🔖 Tags: 0 -🧩 Vendor Extensions: 5 +🧩 Vendor Extensions: 6 - x-badges: 1 - x-codeSamples: 2 - x-hideReplay: 1 - x-internal: 2 - x-metadata: 1 + - x-paths-ext: 1 Document: openapi.yaml stats: From 15e669c504063998bacff696d6d05ed1792e4242 Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 5 Aug 2026 16:57:48 +0200 Subject: [PATCH 11/19] feat: add masking for sensitive values in StatsSpecExtensions --- .../other/__tests__/spec-extensions.test.ts | 23 +++++++++++++++++++ .../core/src/rules/other/spec-extensions.ts | 17 +++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/core/src/rules/other/__tests__/spec-extensions.test.ts b/packages/core/src/rules/other/__tests__/spec-extensions.test.ts index 2e8d6477d8..76951f6329 100644 --- a/packages/core/src/rules/other/__tests__/spec-extensions.test.ts +++ b/packages/core/src/rules/other/__tests__/spec-extensions.test.ts @@ -254,6 +254,29 @@ describe('StatsSpecExtensions', () => { expect(props(acc, 'x-codeSamples', 'lang')).toEqual(['curl']); }); + it('should mask sensitive values by key and by value shape, keeping benign ones', async () => { + const acc = await collect(outdent` + openapi: 3.1.0 + info: + title: t + version: '1' + x-auth-token: benign-but-key-is-sensitive + x-gateway: + apiKey: abc123 + url: https://internal.corp/api + contact: jane.doe@corp.com + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + color: purple + `); + + expect(props(acc, 'x-auth-token', '$value')).toEqual(['']); + expect(props(acc, 'x-gateway', 'apiKey')).toEqual(['']); + expect(props(acc, 'x-gateway', 'url')).toEqual(['']); + expect(props(acc, 'x-gateway', 'contact')).toEqual(['']); + expect(props(acc, 'x-gateway', 'traceId')).toEqual(['']); + expect(props(acc, 'x-gateway', 'color')).toEqual(['purple']); + }); + it('should collect the extension value under $value when it has no own props', async () => { const acc = await collect(outdent` openapi: 3.1.0 diff --git a/packages/core/src/rules/other/spec-extensions.ts b/packages/core/src/rules/other/spec-extensions.ts index d9344d0eb2..43b82cbca0 100644 --- a/packages/core/src/rules/other/spec-extensions.ts +++ b/packages/core/src/rules/other/spec-extensions.ts @@ -15,6 +15,14 @@ const MAX_PROPS_PER_EXTENSION = 20; const VALUE_KEY = '$value'; // holds a scalar extension's own value, e.g. `x-hideReplay: true` const TRUNCATED = ''; +const MASKED = ''; + +// Keys that suggest a credential or personal data — their values are never sampled. +const SENSITIVE_KEY_REGEX = /key|token|secret|password|credential|session|bearer|email|auth\b/i; +// Value shapes masked regardless of the key: opaque token-like blobs, emails, URLs with a scheme. +const TOKEN_LIKE_REGEX = /^(?=.*\d)[A-Za-z0-9+/=_-]{16,}$/; +const EMAIL_REGEX = /\S@\S+\.\S/; +const URL_SCHEME_REGEX = /:\/\//; export const StatsSpecExtensions = (accumulator: SpecVendorExtensionsAccumulator) => { return { @@ -61,7 +69,11 @@ function recordExtension( const entry = (accumulator[key] ??= { count: 0, props: {} }); entry.count++; for (const [prop, propValue] of getExtensionProps(value)) { - addSample(entry.props, prop, describe(propValue)); + const sample = + SENSITIVE_KEY_REGEX.test(key) || SENSITIVE_KEY_REGEX.test(prop) + ? MASKED + : describe(propValue); + addSample(entry.props, prop, sample); } } @@ -77,6 +89,9 @@ function describe(value: unknown): string { if (value === null) return ''; if (typeof value === 'boolean' || typeof value === 'number') return String(value); if (typeof value === 'string') { + if (TOKEN_LIKE_REGEX.test(value) || EMAIL_REGEX.test(value) || URL_SCHEME_REGEX.test(value)) { + return MASKED; + } return value.length <= MAX_VALUE_LENGTH ? value : ``; } if (Array.isArray(value)) return ``; From c99afa02f40a3e1d1f5db6185d14e0f4c33f7f91 Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 5 Aug 2026 17:04:18 +0200 Subject: [PATCH 12/19] feat: add authorization key to be masked in StatsSpecExtensions tests --- packages/core/src/rules/other/__tests__/spec-extensions.test.ts | 2 ++ packages/core/src/rules/other/spec-extensions.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/rules/other/__tests__/spec-extensions.test.ts b/packages/core/src/rules/other/__tests__/spec-extensions.test.ts index 76951f6329..3846b3957d 100644 --- a/packages/core/src/rules/other/__tests__/spec-extensions.test.ts +++ b/packages/core/src/rules/other/__tests__/spec-extensions.test.ts @@ -263,6 +263,7 @@ describe('StatsSpecExtensions', () => { x-auth-token: benign-but-key-is-sensitive x-gateway: apiKey: abc123 + authorization: Basic abc url: https://internal.corp/api contact: jane.doe@corp.com traceId: 4bf92f3577b34da6a3ce929d0e0e4736 @@ -271,6 +272,7 @@ describe('StatsSpecExtensions', () => { expect(props(acc, 'x-auth-token', '$value')).toEqual(['']); expect(props(acc, 'x-gateway', 'apiKey')).toEqual(['']); + expect(props(acc, 'x-gateway', 'authorization')).toEqual(['']); expect(props(acc, 'x-gateway', 'url')).toEqual(['']); expect(props(acc, 'x-gateway', 'contact')).toEqual(['']); expect(props(acc, 'x-gateway', 'traceId')).toEqual(['']); diff --git a/packages/core/src/rules/other/spec-extensions.ts b/packages/core/src/rules/other/spec-extensions.ts index 43b82cbca0..9c452d558f 100644 --- a/packages/core/src/rules/other/spec-extensions.ts +++ b/packages/core/src/rules/other/spec-extensions.ts @@ -18,7 +18,7 @@ const TRUNCATED = ''; const MASKED = ''; // Keys that suggest a credential or personal data — their values are never sampled. -const SENSITIVE_KEY_REGEX = /key|token|secret|password|credential|session|bearer|email|auth\b/i; +const SENSITIVE_KEY_REGEX = /key|token|secret|password|credential|session|bearer|email|auth/i; // Value shapes masked regardless of the key: opaque token-like blobs, emails, URLs with a scheme. const TOKEN_LIKE_REGEX = /^(?=.*\d)[A-Za-z0-9+/=_-]{16,}$/; const EMAIL_REGEX = /\S@\S+\.\S/; From 3bb9922ded5252164426db854c693ded254a8c96 Mon Sep 17 00:00:00 2001 From: Vlad Date: Thu, 6 Aug 2026 12:01:51 +0200 Subject: [PATCH 13/19] chore: refactor after cr --- packages/cli/src/commands/stats/index.ts | 9 +-- packages/core/src/index.ts | 1 - packages/core/src/rules/other/stats.ts | 32 ++++++++- packages/core/src/typings/common.ts | 1 + .../__tests__/spec-extensions.test.ts | 72 +++++++++++++++---- .../{rules/other => utils}/spec-extensions.ts | 36 +++++----- 6 files changed, 110 insertions(+), 41 deletions(-) rename packages/core/src/{rules/other => utils}/__tests__/spec-extensions.test.ts (80%) rename packages/core/src/{rules/other => utils}/spec-extensions.ts (84%) diff --git a/packages/cli/src/commands/stats/index.ts b/packages/cli/src/commands/stats/index.ts index 3997f7f655..071a828574 100644 --- a/packages/cli/src/commands/stats/index.ts +++ b/packages/cli/src/commands/stats/index.ts @@ -7,11 +7,8 @@ import { normalizeVisitors, walkDocument, bundle, - StatsSpecExtensions, - applySpecExtensionsStats, type WalkContext, type OutputFormat, - type SpecVendorExtensionsAccumulator, } from '@redocly/openapi-core'; import { performance } from 'perf_hooks'; @@ -50,14 +47,12 @@ export async function handleStats({ argv, config, collectSpecData }: CommandArgs externalRefResolver, }); - const extensionsAccumulator: SpecVendorExtensionsAccumulator = {}; - const normalizedStatsVisitor = normalizeVisitors( [ { severity: 'warn', ruleId: 'stats', - visitor: { ...statsVisitor, ...StatsSpecExtensions(extensionsAccumulator) }, + visitor: statsVisitor, }, ], types @@ -71,7 +66,5 @@ export async function handleStats({ argv, config, collectSpecData }: CommandArgs ctx, }); - applySpecExtensionsStats(extensionsAccumulator, statsAccumulator.xExtensions); - printStats(statsAccumulator, path, startedAt, argv.format); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dd5135dd21..027bc7094d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,7 +26,6 @@ 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 { StatsSpecExtensions, applySpecExtensionsStats } from './rules/other/spec-extensions.js'; export { loadConfig, loadIgnoreConfig, diff --git a/packages/core/src/rules/other/stats.ts b/packages/core/src/rules/other/stats.ts index 23f17d94f2..e4d80490a6 100644 --- a/packages/core/src/rules/other/stats.ts +++ b/packages/core/src/rules/other/stats.ts @@ -1,4 +1,8 @@ -import type { OASStatsAccumulator, AsyncAPIStatsAccumulator } from '../../typings/common.js'; +import type { + OASStatsAccumulator, + AsyncAPIStatsAccumulator, + SpecVendorExtensionsAccumulator, +} from '../../typings/common.js'; import type { Oas3Link, Oas3Operation, @@ -8,9 +12,18 @@ import type { OasRef, } from '../../typings/openapi.js'; import type { Oas2Parameter } from '../../typings/swagger.js'; +import { applySpecExtensionsStats, collectSpecExtensions } from '../../utils/spec-extensions.js'; +import type { UserContext } from '../../walk.js'; export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => { + const extensions: SpecVendorExtensionsAccumulator = {}; + return { + any: { + enter(node: unknown, ctx: UserContext) { + collectSpecExtensions(extensions, node, ctx); + }, + }, ExternalDocs: { leave() { statsAccumulator.externalDocs.total++; @@ -78,13 +91,21 @@ export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => { statsAccumulator.refs.total = statsAccumulator.refs.items!.size; statsAccumulator.links.total = statsAccumulator.links.items!.size; statsAccumulator.tags.total = statsAccumulator.tags.items!.size; + applySpecExtensionsStats(extensions, statsAccumulator.xExtensions); }, }, }; }; export const StatsAsync2 = (statsAccumulator: AsyncAPIStatsAccumulator) => { + const extensions: SpecVendorExtensionsAccumulator = {}; + return { + any: { + enter(node: unknown, ctx: UserContext) { + collectSpecExtensions(extensions, node, ctx); + }, + }, ExternalDocs: { leave() { statsAccumulator.externalDocs.total++; @@ -136,13 +157,21 @@ export const StatsAsync2 = (statsAccumulator: AsyncAPIStatsAccumulator) => { statsAccumulator.parameters.total = statsAccumulator.parameters.items!.size; statsAccumulator.refs.total = statsAccumulator.refs.items!.size; statsAccumulator.tags.total = statsAccumulator.tags.items!.size; + applySpecExtensionsStats(extensions, statsAccumulator.xExtensions); }, }, }; }; export const StatsAsync3 = (statsAccumulator: AsyncAPIStatsAccumulator) => { + const extensions: SpecVendorExtensionsAccumulator = {}; + return { + any: { + enter(node: unknown, ctx: UserContext) { + collectSpecExtensions(extensions, node, ctx); + }, + }, ExternalDocs: { leave() { statsAccumulator.externalDocs.total++; @@ -196,6 +225,7 @@ export const StatsAsync3 = (statsAccumulator: AsyncAPIStatsAccumulator) => { statsAccumulator.parameters.total = statsAccumulator.parameters.items!.size; statsAccumulator.refs.total = statsAccumulator.refs.items!.size; statsAccumulator.tags.total = statsAccumulator.tags.items!.size; + applySpecExtensionsStats(extensions, statsAccumulator.xExtensions); }, }, }; diff --git a/packages/core/src/typings/common.ts b/packages/core/src/typings/common.ts index 13206cd303..cbbed5088a 100644 --- a/packages/core/src/typings/common.ts +++ b/packages/core/src/typings/common.ts @@ -4,6 +4,7 @@ export interface StatsRow { color: 'red' | 'yellow' | 'green' | 'white' | 'magenta' | 'cyan'; items?: Set; counts?: Record; + details?: SpecVendorExtensionsAccumulator; } export type OASStatsName = diff --git a/packages/core/src/rules/other/__tests__/spec-extensions.test.ts b/packages/core/src/utils/__tests__/spec-extensions.test.ts similarity index 80% rename from packages/core/src/rules/other/__tests__/spec-extensions.test.ts rename to packages/core/src/utils/__tests__/spec-extensions.test.ts index 3846b3957d..889b9270df 100644 --- a/packages/core/src/rules/other/__tests__/spec-extensions.test.ts +++ b/packages/core/src/utils/__tests__/spec-extensions.test.ts @@ -1,23 +1,69 @@ import { outdent } from 'outdent'; -import { parseYamlToDocument } from '../../../../__tests__/utils.js'; -import { detectSpec } from '../../../detect-spec.js'; -import { getTypes } from '../../../oas-types.js'; -import { BaseResolver, resolveDocument } from '../../../resolve.js'; -import { normalizeTypes } from '../../../types/index.js'; -import type { SpecVendorExtensionsAccumulator, StatsRow } from '../../../typings/common.js'; -import { normalizeVisitors } from '../../../visitors.js'; -import { walkDocument } from '../../../walk.js'; -import { StatsSpecExtensions, applySpecExtensionsStats } from '../spec-extensions.js'; +import { parseYamlToDocument } from '../../../__tests__/utils.js'; +import { detectSpec } from '../../detect-spec.js'; +import { getTypes } from '../../oas-types.js'; +import { BaseResolver, resolveDocument } from '../../resolve.js'; +import { StatsAsync2, StatsAsync3, StatsOAS } from '../../rules/other/stats.js'; +import { normalizeTypes } from '../../types/index.js'; +import type { + AsyncAPIStatsAccumulator, + OASStatsAccumulator, + SpecVendorExtensionsAccumulator, + StatsRow, +} from '../../typings/common.js'; +import { normalizeVisitors } from '../../visitors.js'; +import { walkDocument } from '../../walk.js'; +import { applySpecExtensionsStats } from '../spec-extensions.js'; + +function createOasStatsAccumulator(): OASStatsAccumulator { + return { + refs: { metric: 'References', total: 0, color: 'red', items: new Set() }, + externalDocs: { metric: 'External Documents', total: 0, color: 'magenta' }, + schemas: { metric: 'Schemas', total: 0, color: 'white' }, + parameters: { metric: 'Parameters', total: 0, color: 'yellow', items: new Set() }, + links: { metric: 'Links', total: 0, color: 'cyan', items: new Set() }, + pathItems: { metric: 'Path Items', total: 0, color: 'green' }, + 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' }, + }; +} + +function createAsyncStatsAccumulator(): AsyncAPIStatsAccumulator { + return { + refs: { metric: 'References', total: 0, color: 'red', items: new Set() }, + externalDocs: { metric: 'External Documents', total: 0, color: 'magenta' }, + schemas: { metric: 'Schemas', total: 0, color: 'white' }, + parameters: { metric: 'Parameters', total: 0, color: 'yellow', items: new Set() }, + 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' }, + }; +} async function collect(yaml: string): Promise { const document = parseYamlToDocument(yaml, ''); const specVersion = detectSpec(document.parsed); const types = normalizeTypes(getTypes(specVersion)); - const accumulator: SpecVendorExtensionsAccumulator = {}; + + let statsVisitor; + let xExtensionsRow: StatsRow; + if (specVersion === 'async2' || specVersion === 'async3') { + const statsAccumulator = createAsyncStatsAccumulator(); + statsVisitor = + specVersion === 'async2' ? StatsAsync2(statsAccumulator) : StatsAsync3(statsAccumulator); + xExtensionsRow = statsAccumulator.xExtensions; + } else { + const statsAccumulator = createOasStatsAccumulator(); + statsVisitor = StatsOAS(statsAccumulator); + xExtensionsRow = statsAccumulator.xExtensions; + } const visitors = normalizeVisitors( - [{ severity: 'warn', ruleId: 'test', visitor: StatsSpecExtensions(accumulator) }], + [{ severity: 'warn', ruleId: 'test', visitor: statsVisitor }], types ); const resolvedRefMap = await resolveDocument({ @@ -32,13 +78,13 @@ async function collect(yaml: string): Promise { document, ctx: { problems: [], specVersion, visitorsData: {} }, }); - return accumulator; + return xExtensionsRow.details ?? {}; } const props = (acc: SpecVendorExtensionsAccumulator, name: string, prop: string) => [...(acc[name]?.props[prop] ?? [])].sort(); -describe('StatsSpecExtensions', () => { +describe('stats vendor extensions collection', () => { it('should count every x- key, including extensions that have a declared type in core', async () => { const acc = await collect(outdent` openapi: 3.1.0 diff --git a/packages/core/src/rules/other/spec-extensions.ts b/packages/core/src/utils/spec-extensions.ts similarity index 84% rename from packages/core/src/rules/other/spec-extensions.ts rename to packages/core/src/utils/spec-extensions.ts index 9c452d558f..3533d376a2 100644 --- a/packages/core/src/rules/other/spec-extensions.ts +++ b/packages/core/src/utils/spec-extensions.ts @@ -1,9 +1,9 @@ -import { isRef } from '../../ref-utils.js'; -import { isNamedType, SpecExtension, type NormalizedNodeType } from '../../types/index.js'; -import type { StatsRow, SpecVendorExtensionsAccumulator } from '../../typings/common.js'; -import { getOwn } from '../../utils/get-own.js'; -import { isPlainObject } from '../../utils/is-plain-object.js'; -import type { UserContext } from '../../walk.js'; +import { isRef } from '../ref-utils.js'; +import { isNamedType, SpecExtension, type NormalizedNodeType } from '../types/index.js'; +import type { StatsRow, SpecVendorExtensionsAccumulator } from '../typings/common.js'; +import type { UserContext } from '../walk.js'; +import { getOwn } from './get-own.js'; +import { isPlainObject } from './is-plain-object.js'; const EXTENSION_PREFIX = 'x-'; @@ -24,19 +24,18 @@ const TOKEN_LIKE_REGEX = /^(?=.*\d)[A-Za-z0-9+/=_-]{16,}$/; const EMAIL_REGEX = /\S@\S+\.\S/; const URL_SCHEME_REGEX = /:\/\//; -export const StatsSpecExtensions = (accumulator: SpecVendorExtensionsAccumulator) => { - return { - any: { - enter(node: unknown, ctx: UserContext) { - if (ctx.type === SpecExtension) return; +// Spec-agnostic collector the stats rules call from their `any` hook. +export function collectSpecExtensions( + accumulator: SpecVendorExtensionsAccumulator, + node: unknown, + ctx: UserContext +) { + if (ctx.type === SpecExtension) return; - recordExtensions(accumulator, node, ctx.type); - // Extensions written next to a $ref sit on the raw node, not the resolved target. - if (isRef(ctx.rawNode)) recordExtensions(accumulator, ctx.rawNode, ctx.type); - }, - }, - }; -}; + recordExtensions(accumulator, node, ctx.type); + // Extensions written next to a $ref sit on the raw node, not the resolved target. + if (isRef(ctx.rawNode)) recordExtensions(accumulator, ctx.rawNode, ctx.type); +} function recordExtensions( accumulator: SpecVendorExtensionsAccumulator, @@ -119,4 +118,5 @@ export function applySpecExtensionsStats( const names = Object.keys(accumulator).sort(); statsRow.total = names.length; statsRow.counts = Object.fromEntries(names.map((name) => [name, accumulator[name].count])); + statsRow.details = accumulator; } From 13e252e95638f256835a0675a874b3eb534309d0 Mon Sep 17 00:00:00 2001 From: Vlad Date: Thu, 6 Aug 2026 12:19:08 +0200 Subject: [PATCH 14/19] feat: add support for collecting sibling extensions in stats processing --- packages/core/src/rules/other/stats.ts | 9 ++++-- .../utils/__tests__/spec-extensions.test.ts | 29 +++++++++++++++++++ packages/core/src/utils/spec-extensions.ts | 7 ++--- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/core/src/rules/other/stats.ts b/packages/core/src/rules/other/stats.ts index e4d80490a6..0d233b73df 100644 --- a/packages/core/src/rules/other/stats.ts +++ b/packages/core/src/rules/other/stats.ts @@ -30,8 +30,9 @@ export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => { }, }, ref: { - enter(ref: OasRef) { + enter(ref: OasRef, ctx: UserContext) { statsAccumulator.refs.items!.add(ref['$ref']); + collectSpecExtensions(extensions, ref, ctx); }, }, Tag: { @@ -112,8 +113,9 @@ export const StatsAsync2 = (statsAccumulator: AsyncAPIStatsAccumulator) => { }, }, ref: { - enter(ref: OasRef) { + enter(ref: OasRef, ctx: UserContext) { statsAccumulator.refs.items!.add(ref['$ref']); + collectSpecExtensions(extensions, ref, ctx); }, }, Tag: { @@ -178,8 +180,9 @@ export const StatsAsync3 = (statsAccumulator: AsyncAPIStatsAccumulator) => { }, }, ref: { - enter(ref: OasRef) { + enter(ref: OasRef, ctx: UserContext) { statsAccumulator.refs.items!.add(ref['$ref']); + collectSpecExtensions(extensions, ref, ctx); }, }, Tag: { diff --git a/packages/core/src/utils/__tests__/spec-extensions.test.ts b/packages/core/src/utils/__tests__/spec-extensions.test.ts index 889b9270df..15caebe111 100644 --- a/packages/core/src/utils/__tests__/spec-extensions.test.ts +++ b/packages/core/src/utils/__tests__/spec-extensions.test.ts @@ -172,6 +172,35 @@ describe('stats vendor extensions collection', () => { expect(acc['x-hideReplay']?.count).toBe(1); }); + it('should count a sibling extension on a later $ref to an already-visited target', async () => { + const acc = await collect(outdent` + openapi: 3.1.0 + info: + title: t + version: '1' + paths: + /a: + get: + operationId: a + responses: + '200': + $ref: '#/components/responses/Shared' + /b: + get: + operationId: b + responses: + '200': + $ref: '#/components/responses/Shared' + x-second-ref-ext: true + components: + responses: + Shared: + description: ok + `); + + expect(acc['x-second-ref-ext']?.count).toBe(1); + }); + it('should not descend into an extension value (no props-of-props)', async () => { const acc = await collect(outdent` openapi: 3.1.0 diff --git a/packages/core/src/utils/spec-extensions.ts b/packages/core/src/utils/spec-extensions.ts index 3533d376a2..e02578e6c4 100644 --- a/packages/core/src/utils/spec-extensions.ts +++ b/packages/core/src/utils/spec-extensions.ts @@ -1,4 +1,3 @@ -import { isRef } from '../ref-utils.js'; import { isNamedType, SpecExtension, type NormalizedNodeType } from '../types/index.js'; import type { StatsRow, SpecVendorExtensionsAccumulator } from '../typings/common.js'; import type { UserContext } from '../walk.js'; @@ -24,17 +23,15 @@ const TOKEN_LIKE_REGEX = /^(?=.*\d)[A-Za-z0-9+/=_-]{16,}$/; const EMAIL_REGEX = /\S@\S+\.\S/; const URL_SCHEME_REGEX = /:\/\//; -// Spec-agnostic collector the stats rules call from their `any` hook. +// Spec-agnostic collector the stats rules call from their `any` and `ref` hooks. export function collectSpecExtensions( accumulator: SpecVendorExtensionsAccumulator, node: unknown, ctx: UserContext ) { - if (ctx.type === SpecExtension) return; + if (ctx.type === SpecExtension || ctx.type.name === 'scalar') return; recordExtensions(accumulator, node, ctx.type); - // Extensions written next to a $ref sit on the raw node, not the resolved target. - if (isRef(ctx.rawNode)) recordExtensions(accumulator, ctx.rawNode, ctx.type); } function recordExtensions( From d1430fd822b5f9729f7e99c2c98b6277f283d182 Mon Sep 17 00:00:00 2001 From: Vlad Date: Thu, 6 Aug 2026 15:51:23 +0200 Subject: [PATCH 15/19] chore: update docs examples --- .changeset/seven-waves-create.md | 2 +- docs/@v2/commands/stats.md | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.changeset/seven-waves-create.md b/.changeset/seven-waves-create.md index 407ffe170c..707caa43be 100644 --- a/.changeset/seven-waves-create.md +++ b/.changeset/seven-waves-create.md @@ -3,4 +3,4 @@ '@redocly/cli': minor --- -Added a Vendor Extensions metric to the `stats` command that reports how many distinct `x-` extensions a descrtption file uses and how often each one occurs. +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. diff --git a/docs/@v2/commands/stats.md b/docs/@v2/commands/stats.md index 9344b71685..ad3e2116b0 100644 --- a/docs/@v2/commands/stats.md +++ b/docs/@v2/commands/stats.md @@ -127,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 @@ -143,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 @@ -191,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 + } } } @@ -218,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 | @@ -234,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. From a7053a7a047056f833d530cab3a2955d77872c26 Mon Sep 17 00:00:00 2001 From: Vlad Date: Fri, 7 Aug 2026 09:29:29 +0200 Subject: [PATCH 16/19] feat: enhance stats processing for vendor extensions with detailed counts and new test cases --- .../src/commands/stats/print-stats/json.ts | 13 +++++++------ .../commands/stats/print-stats/markdown.ts | 6 +++--- .../src/commands/stats/print-stats/stylish.ts | 4 ++-- .../stats/visitor-and-accumulator-resolver.ts | 4 ++-- packages/core/src/typings/common.ts | 1 - packages/core/src/utils/spec-extensions.ts | 9 ++++----- .../e2e/stats/stats-extensions/asyncapi.yaml | 19 +++++++++++++++++++ .../snapshot-asyncapi-stylish.txt | 17 +++++++++++++++++ tests/e2e/stats/stats.test.ts | 9 +++++++++ 9 files changed, 63 insertions(+), 19 deletions(-) create mode 100644 tests/e2e/stats/stats-extensions/asyncapi.yaml create mode 100644 tests/e2e/stats/stats-extensions/snapshot-asyncapi-stylish.txt diff --git a/packages/cli/src/commands/stats/print-stats/json.ts b/packages/cli/src/commands/stats/print-stats/json.ts index 8431afa46d..22ce0e94ab 100644 --- a/packages/cli/src/commands/stats/print-stats/json.ts +++ b/packages/cli/src/commands/stats/print-stats/json.ts @@ -7,12 +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, - ...(stat.counts && { counts: stat.counts }), - }; + 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)); diff --git a/packages/cli/src/commands/stats/print-stats/markdown.ts b/packages/cli/src/commands/stats/print-stats/markdown.ts index 6e3ba0293a..84663c9652 100644 --- a/packages/cli/src/commands/stats/print-stats/markdown.ts +++ b/packages/cli/src/commands/stats/print-stats/markdown.ts @@ -12,11 +12,11 @@ export function printStatsMarkdown( for (const key of Object.keys(statsAccumulator)) { const stat = statsAccumulator[key as keyof typeof statsAccumulator]; output += '| ' + stat.metric + ' | ' + stat.total + ' |\n'; - const counts = Object.entries(stat.counts || {}); - if (counts.length) { + const details = Object.entries(stat.details || {}); + if (details.length) { breakdowns.push( `\n#### ${stat.metric}\n| Extension | Count |\n| --- | --- |\n` + - counts.map(([name, count]) => `| ${name} | ${count} |`).join('\n') + + details.map(([name, { count }]) => `| ${name} | ${count} |`).join('\n') + '\n' ); } diff --git a/packages/cli/src/commands/stats/print-stats/stylish.ts b/packages/cli/src/commands/stats/print-stats/stylish.ts index 06d64dbdb6..9569e00852 100644 --- a/packages/cli/src/commands/stats/print-stats/stylish.ts +++ b/packages/cli/src/commands/stats/print-stats/stylish.ts @@ -10,10 +10,10 @@ export function printStatsStylish( ) { for (const node in statsAccumulator) { const stat = statsAccumulator[node as keyof typeof statsAccumulator]; - const { metric, total, color, counts } = 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(counts || {})) { + for (const [name, { count }] of Object.entries(details || {})) { logger.output(colorFn(` - ${name}: ${count} \n`)); } } diff --git a/packages/cli/src/commands/stats/visitor-and-accumulator-resolver.ts b/packages/cli/src/commands/stats/visitor-and-accumulator-resolver.ts index 9fa0a93532..480b7385e0 100644 --- a/packages/cli/src/commands/stats/visitor-and-accumulator-resolver.ts +++ b/packages/cli/src/commands/stats/visitor-and-accumulator-resolver.ts @@ -20,7 +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', counts: {} }, + xExtensions: { metric: '🧩 Vendor Extensions', total: 0, color: 'cyan' }, }; const statsAccumulatorAsync: AsyncAPIStatsAccumulator = { refs: { metric: '🚗 References', total: 0, color: 'red', items: new Set() }, @@ -30,7 +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', counts: {} }, + xExtensions: { metric: '🧩 Vendor Extensions', total: 0, color: 'cyan' }, }; let statsVisitor, statsAccumulator; diff --git a/packages/core/src/typings/common.ts b/packages/core/src/typings/common.ts index cbbed5088a..de32a02997 100644 --- a/packages/core/src/typings/common.ts +++ b/packages/core/src/typings/common.ts @@ -3,7 +3,6 @@ export interface StatsRow { total: number; color: 'red' | 'yellow' | 'green' | 'white' | 'magenta' | 'cyan'; items?: Set; - counts?: Record; details?: SpecVendorExtensionsAccumulator; } diff --git a/packages/core/src/utils/spec-extensions.ts b/packages/core/src/utils/spec-extensions.ts index e02578e6c4..f6b862ffee 100644 --- a/packages/core/src/utils/spec-extensions.ts +++ b/packages/core/src/utils/spec-extensions.ts @@ -54,7 +54,7 @@ function isMapEntryKey(type: NormalizedNodeType, key: string, value: unknown): b typeof type.additionalProperties === 'function' ? type.additionalProperties(value, key) : type.additionalProperties; - return isNamedType(entryType); + return isNamedType(entryType) || typeof entryType?.type === 'string'; } function recordExtension( @@ -109,11 +109,10 @@ function addBounded(set: Set, value: string) { } export function applySpecExtensionsStats( - accumulator: SpecVendorExtensionsAccumulator, + collectedExtensions: SpecVendorExtensionsAccumulator, statsRow: StatsRow ) { - const names = Object.keys(accumulator).sort(); + const names = Object.keys(collectedExtensions).sort(); statsRow.total = names.length; - statsRow.counts = Object.fromEntries(names.map((name) => [name, accumulator[name].count])); - statsRow.details = accumulator; + statsRow.details = Object.fromEntries(names.map((name) => [name, collectedExtensions[name]])); } diff --git a/tests/e2e/stats/stats-extensions/asyncapi.yaml b/tests/e2e/stats/stats-extensions/asyncapi.yaml new file mode 100644 index 0000000000..7ee8cf285b --- /dev/null +++ b/tests/e2e/stats/stats-extensions/asyncapi.yaml @@ -0,0 +1,19 @@ +asyncapi: '2.6.0' +info: + title: AsyncAPI vendor extensions fixture + version: '1.0.0' + x-metadata: + team: Docs +channels: + user/signedup: + x-channel-ext: true + subscribe: + operationId: userSignedUp + x-internal: true + message: + x-internal: true + payload: + type: object + properties: + userId: + type: string diff --git a/tests/e2e/stats/stats-extensions/snapshot-asyncapi-stylish.txt b/tests/e2e/stats/stats-extensions/snapshot-asyncapi-stylish.txt new file mode 100644 index 0000000000..26c75c244e --- /dev/null +++ b/tests/e2e/stats/stats-extensions/snapshot-asyncapi-stylish.txt @@ -0,0 +1,17 @@ +🚗 References: 0 +📦 External Documents: 0 +📈 Schemas: 0 +👉 Parameters: 0 +📡 Channels: 1 +👷 Operations: 1 +🔖 Tags: 0 +🧩 Vendor Extensions: 3 + - x-channel-ext: 1 + - x-internal: 2 + - x-metadata: 1 + +Document: asyncapi.yaml stats: + + +asyncapi.yaml: stats processed in ms + diff --git a/tests/e2e/stats/stats.test.ts b/tests/e2e/stats/stats.test.ts index f347f9f661..9723b27fac 100644 --- a/tests/e2e/stats/stats.test.ts +++ b/tests/e2e/stats/stats.test.ts @@ -65,6 +65,15 @@ describe('stats', () => { await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot-stylish.txt')); }); + test('stats should report vendor extension counts for AsyncAPI (stylish format)', async () => { + const testPath = join(folderPath, 'stats-extensions'); + const args = getParams(indexEntryPoint, ['stats', 'asyncapi.yaml']); + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(testPath, 'snapshot-asyncapi-stylish.txt') + ); + }); + test('stats should report vendor extension counts (Markdown format)', async () => { const testPath = join(folderPath, 'stats-extensions'); const args = getParams(indexEntryPoint, ['stats', 'openapi.yaml', '--format=markdown']); From a68162f97c083f3265e9e0ea5117e7cd558ec9c9 Mon Sep 17 00:00:00 2001 From: Vlad Date: Fri, 7 Aug 2026 12:21:08 +0200 Subject: [PATCH 17/19] feat: implement spec extension dispatch and enhance stats collection for vendor extensions --- packages/cli/src/commands/stats/index.ts | 2 + packages/core/src/index.ts | 1 + packages/core/src/rules/other/stats.ts | 66 ++++++----- .../utils/__tests__/spec-extensions.test.ts | 104 +++++++++++------- packages/core/src/utils/spec-extensions.ts | 63 ++++------- packages/core/src/walk.ts | 9 +- 6 files changed, 134 insertions(+), 111 deletions(-) diff --git a/packages/cli/src/commands/stats/index.ts b/packages/cli/src/commands/stats/index.ts index 071a828574..f4b910a99e 100644 --- a/packages/cli/src/commands/stats/index.ts +++ b/packages/cli/src/commands/stats/index.ts @@ -7,6 +7,7 @@ import { normalizeVisitors, walkDocument, bundle, + ensureSpecExtensionDispatch, type WalkContext, type OutputFormat, } from '@redocly/openapi-core'; @@ -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); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 959dd4e7b5..69fe666653 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/rules/other/stats.ts b/packages/core/src/rules/other/stats.ts index 0d233b73df..5d7eeaa6a4 100644 --- a/packages/core/src/rules/other/stats.ts +++ b/packages/core/src/rules/other/stats.ts @@ -2,6 +2,7 @@ import type { OASStatsAccumulator, AsyncAPIStatsAccumulator, SpecVendorExtensionsAccumulator, + StatsAccumulator, } from '../../typings/common.js'; import type { Oas3Link, @@ -12,16 +13,32 @@ import type { OasRef, } from '../../typings/openapi.js'; import type { Oas2Parameter } from '../../typings/swagger.js'; -import { applySpecExtensionsStats, collectSpecExtensions } from '../../utils/spec-extensions.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 { - any: { + SpecExtension: { enter(node: unknown, ctx: UserContext) { - collectSpecExtensions(extensions, node, ctx); + collectSpecExtension(extensions, ctx.key.toString(), node); }, }, ExternalDocs: { @@ -30,9 +47,8 @@ export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => { }, }, ref: { - enter(ref: OasRef, ctx: UserContext) { + enter(ref: OasRef) { statsAccumulator.refs.items!.add(ref['$ref']); - collectSpecExtensions(extensions, ref, ctx); }, }, Tag: { @@ -46,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++; @@ -63,6 +84,11 @@ export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => { statsAccumulator.pathItems.total++; }, Operation: { + enter(operation: Oas3Operation, ctx: UserContext) { + if (ctx.key === 'x-query') { + collectSpecExtension(extensions, 'x-query', operation); + } + }, leave(operation: Oas3Operation) { statsAccumulator.operations.total++; if (operation.tags) { @@ -88,11 +114,7 @@ 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; - applySpecExtensionsStats(extensions, statsAccumulator.xExtensions); + finalizeStats(statsAccumulator, extensions); }, }, }; @@ -102,9 +124,9 @@ export const StatsAsync2 = (statsAccumulator: AsyncAPIStatsAccumulator) => { const extensions: SpecVendorExtensionsAccumulator = {}; return { - any: { + SpecExtension: { enter(node: unknown, ctx: UserContext) { - collectSpecExtensions(extensions, node, ctx); + collectSpecExtension(extensions, ctx.key.toString(), node); }, }, ExternalDocs: { @@ -113,9 +135,8 @@ export const StatsAsync2 = (statsAccumulator: AsyncAPIStatsAccumulator) => { }, }, ref: { - enter(ref: OasRef, ctx: UserContext) { + enter(ref: OasRef) { statsAccumulator.refs.items!.add(ref['$ref']); - collectSpecExtensions(extensions, ref, ctx); }, }, Tag: { @@ -156,10 +177,7 @@ 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; - applySpecExtensionsStats(extensions, statsAccumulator.xExtensions); + finalizeStats(statsAccumulator, extensions); }, }, }; @@ -169,9 +187,9 @@ export const StatsAsync3 = (statsAccumulator: AsyncAPIStatsAccumulator) => { const extensions: SpecVendorExtensionsAccumulator = {}; return { - any: { + SpecExtension: { enter(node: unknown, ctx: UserContext) { - collectSpecExtensions(extensions, node, ctx); + collectSpecExtension(extensions, ctx.key.toString(), node); }, }, ExternalDocs: { @@ -180,9 +198,8 @@ export const StatsAsync3 = (statsAccumulator: AsyncAPIStatsAccumulator) => { }, }, ref: { - enter(ref: OasRef, ctx: UserContext) { + enter(ref: OasRef) { statsAccumulator.refs.items!.add(ref['$ref']); - collectSpecExtensions(extensions, ref, ctx); }, }, Tag: { @@ -225,10 +242,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; - applySpecExtensionsStats(extensions, statsAccumulator.xExtensions); + finalizeStats(statsAccumulator, extensions); }, }, }; diff --git a/packages/core/src/utils/__tests__/spec-extensions.test.ts b/packages/core/src/utils/__tests__/spec-extensions.test.ts index 15caebe111..9e8e2ef47c 100644 --- a/packages/core/src/utils/__tests__/spec-extensions.test.ts +++ b/packages/core/src/utils/__tests__/spec-extensions.test.ts @@ -10,11 +10,10 @@ import type { AsyncAPIStatsAccumulator, OASStatsAccumulator, SpecVendorExtensionsAccumulator, - StatsRow, } from '../../typings/common.js'; import { normalizeVisitors } from '../../visitors.js'; import { walkDocument } from '../../walk.js'; -import { applySpecExtensionsStats } from '../spec-extensions.js'; +import { ensureSpecExtensionDispatch } from '../spec-extensions.js'; function createOasStatsAccumulator(): OASStatsAccumulator { return { @@ -44,22 +43,23 @@ function createAsyncStatsAccumulator(): AsyncAPIStatsAccumulator { }; } -async function collect(yaml: string): Promise { +async function walkStats(yaml: string): Promise { const document = parseYamlToDocument(yaml, ''); const specVersion = detectSpec(document.parsed); const types = normalizeTypes(getTypes(specVersion)); + ensureSpecExtensionDispatch(types); let statsVisitor; - let xExtensionsRow: StatsRow; + let statsAccumulator: OASStatsAccumulator | AsyncAPIStatsAccumulator; if (specVersion === 'async2' || specVersion === 'async3') { - const statsAccumulator = createAsyncStatsAccumulator(); + const asyncAccumulator = createAsyncStatsAccumulator(); statsVisitor = - specVersion === 'async2' ? StatsAsync2(statsAccumulator) : StatsAsync3(statsAccumulator); - xExtensionsRow = statsAccumulator.xExtensions; + specVersion === 'async2' ? StatsAsync2(asyncAccumulator) : StatsAsync3(asyncAccumulator); + statsAccumulator = asyncAccumulator; } else { - const statsAccumulator = createOasStatsAccumulator(); - statsVisitor = StatsOAS(statsAccumulator); - xExtensionsRow = statsAccumulator.xExtensions; + const oasAccumulator = createOasStatsAccumulator(); + statsVisitor = StatsOAS(oasAccumulator); + statsAccumulator = oasAccumulator; } const visitors = normalizeVisitors( @@ -78,7 +78,11 @@ async function collect(yaml: string): Promise { document, ctx: { problems: [], specVersion, visitorsData: {} }, }); - return xExtensionsRow.details ?? {}; + return statsAccumulator; +} + +async function collect(yaml: string): Promise { + return (await walkStats(yaml)).xExtensions.details ?? {}; } const props = (acc: SpecVendorExtensionsAccumulator, name: string, prop: string) => @@ -106,6 +110,7 @@ describe('stats vendor extensions collection', () => { description: ok `); + expect(Object.keys(acc)).toEqual(['x-badges', 'x-codeSamples']); expect(acc['x-codeSamples']?.count).toBe(1); expect(acc['x-badges']?.count).toBe(1); }); @@ -234,6 +239,56 @@ describe('stats vendor extensions collection', () => { expect(acc['x-paths-ext']?.count).toBe(1); }); + it('should keep webhook and tag metrics while counting legacy x-webhooks', async () => { + const stats = (await walkStats(outdent` + openapi: 3.0.0 + info: + title: t + version: '1' + paths: {} + x-webhooks: + newPet: + post: + tags: + - pets + responses: + '200': + description: ok + `)) as OASStatsAccumulator; + + expect(stats.webhooks.total).toBe(1); + expect(stats.tags.total).toBe(1); + expect(stats.xExtensions.total).toBe(1); + expect(stats.xExtensions.details?.['x-webhooks']?.count).toBe(1); + }); + + it('should keep operation and tag metrics while counting x-query', async () => { + const stats = (await walkStats(outdent` + openapi: 3.1.0 + info: + title: t + version: '1' + paths: + /a: + get: + operationId: a + responses: + '200': + description: ok + x-query: + operationId: q + tags: + - queries + responses: + '200': + description: ok + `)) as OASStatsAccumulator; + + expect(stats.operations.total).toBe(2); + expect(stats.tags.total).toBe(1); + expect(stats.xExtensions.details?.['x-query']?.count).toBe(1); + }); + it('should not count map keys (schema/component names) that start with x-', async () => { const acc = await collect(outdent` openapi: 3.1.0 @@ -464,31 +519,4 @@ describe('stats vendor extensions collection', () => { expect(propNames).toContain(''); }); }); - - describe('applySpecExtensionsStats', () => { - it('should set total to the distinct extension count and counts per extension', () => { - const acc: SpecVendorExtensionsAccumulator = { - 'x-badges': { count: 3, props: {} }, - 'x-internal': { count: 5, props: {} }, - }; - const row: StatsRow = { metric: 'Vendor Extensions', total: 0, color: 'cyan' }; - - applySpecExtensionsStats(acc, row); - - expect(row.total).toBe(2); - expect(row.counts).toEqual({ 'x-badges': 3, 'x-internal': 5 }); - }); - - it('should sort extension names for a stable output', () => { - const acc: SpecVendorExtensionsAccumulator = { - 'x-zeta': { count: 1, props: {} }, - 'x-alpha': { count: 1, props: {} }, - }; - const row: StatsRow = { metric: 'Vendor Extensions', total: 0, color: 'cyan' }; - - applySpecExtensionsStats(acc, row); - - expect(Object.keys(row.counts!)).toEqual(['x-alpha', 'x-zeta']); - }); - }); }); diff --git a/packages/core/src/utils/spec-extensions.ts b/packages/core/src/utils/spec-extensions.ts index f6b862ffee..6406bb2293 100644 --- a/packages/core/src/utils/spec-extensions.ts +++ b/packages/core/src/utils/spec-extensions.ts @@ -1,7 +1,5 @@ import { isNamedType, SpecExtension, type NormalizedNodeType } from '../types/index.js'; -import type { StatsRow, SpecVendorExtensionsAccumulator } from '../typings/common.js'; -import type { UserContext } from '../walk.js'; -import { getOwn } from './get-own.js'; +import type { SpecVendorExtensionsAccumulator } from '../typings/common.js'; import { isPlainObject } from './is-plain-object.js'; const EXTENSION_PREFIX = 'x-'; @@ -23,41 +21,29 @@ const TOKEN_LIKE_REGEX = /^(?=.*\d)[A-Za-z0-9+/=_-]{16,}$/; const EMAIL_REGEX = /\S@\S+\.\S/; const URL_SCHEME_REGEX = /:\/\//; -// Spec-agnostic collector the stats rules call from their `any` and `ref` hooks. -export function collectSpecExtensions( - accumulator: SpecVendorExtensionsAccumulator, - node: unknown, - ctx: UserContext -) { - if (ctx.type === SpecExtension || ctx.type.name === 'scalar') return; - - recordExtensions(accumulator, node, ctx.type); -} +// Kept typed so other metrics still traverse their subtrees; the stats visitors count them explicitly. +const STRUCTURAL_EXTENSIONS = new Set(['x-webhooks', 'x-query']); -function recordExtensions( - accumulator: SpecVendorExtensionsAccumulator, - node: unknown, - type: NormalizedNodeType -) { - if (!isPlainObject(node)) return; - for (const [key, value] of Object.entries(node)) { - if (!key.startsWith(EXTENSION_PREFIX)) continue; - if (isMapEntryKey(type, key, value)) continue; - recordExtension(accumulator, key, value); +// Makes the walker dispatch every x- key as SpecExtension, including natively-typed ones (x-codeSamples and others). +export function ensureSpecExtensionDispatch(types: Record) { + for (const type of Object.values(types)) { + if (type === SpecExtension) continue; + type.extensionsPrefix ??= EXTENSION_PREFIX; + for (const propName of Object.keys(type.properties)) { + if (propName.startsWith(EXTENSION_PREFIX) && !STRUCTURAL_EXTENSIONS.has(propName)) { + delete type.properties[propName]; + } + } + const entryType = type.additionalProperties; + // An untyped catch-all (`additionalProperties: {}`) swallows x- keys before the extensions fallback. + if (isPlainObject(entryType) && !isNamedType(entryType) && entryType.type === undefined) { + type.additionalProperties = (_value, key: string) => + key.startsWith(EXTENSION_PREFIX) ? SpecExtension : entryType; + } } } -// An x- key is not an extension when the type resolves it to a named map entry (a schema name, a channel address). -function isMapEntryKey(type: NormalizedNodeType, key: string, value: unknown): boolean { - if (getOwn(type.properties, key) !== undefined) return false; - const entryType = - typeof type.additionalProperties === 'function' - ? type.additionalProperties(value, key) - : type.additionalProperties; - return isNamedType(entryType) || typeof entryType?.type === 'string'; -} - -function recordExtension( +export function collectSpecExtension( accumulator: SpecVendorExtensionsAccumulator, key: string, value: unknown @@ -107,12 +93,3 @@ function addBounded(set: Set, value: string) { if (set.has(value) || set.has(TRUNCATED)) return; set.add(set.size >= MAX_VALUES_PER_PROP ? TRUNCATED : value); } - -export function applySpecExtensionsStats( - collectedExtensions: SpecVendorExtensionsAccumulator, - statsRow: StatsRow -) { - const names = Object.keys(collectedExtensions).sort(); - statsRow.total = names.length; - statsRow.details = Object.fromEntries(names.map((name) => [name, collectedExtensions[name]])); -} diff --git a/packages/core/src/walk.ts b/packages/core/src/walk.ts index 486944be73..c50888c54e 100644 --- a/packages/core/src/walk.ts +++ b/packages/core/src/walk.ts @@ -273,7 +273,8 @@ export function walkDocument(opts: { }; currentLocation = resolvedLocation; - const isNodeSeen = seenNodesPerType[type.name]?.has?.(resolvedNode); + const seenKey = type === SpecExtension ? location.absolutePointer : resolvedNode; + const isNodeSeen = seenNodesPerType[type.name]?.has?.(seenKey); let visitedBySome = false; const currentEnterVisitors = @@ -345,7 +346,7 @@ export function walkDocument(opts: { if (visitedBySome || !isNodeSeen) { seenNodesPerType[type.name] = seenNodesPerType[type.name] || new Set(); - seenNodesPerType[type.name].add(resolvedNode); + seenNodesPerType[type.name].add(seenKey); if (Array.isArray(resolvedNode)) { const itemsType = type.items; @@ -375,8 +376,8 @@ export function walkDocument(opts: { props.push(...Object.keys(resolvedNode).filter((k) => !props.includes(k))); } else if (type.extensionsPrefix) { props.push( - ...Object.keys(resolvedNode).filter((k) => - k.startsWith(type.extensionsPrefix as string) + ...Object.keys(resolvedNode).filter( + (k) => k.startsWith(type.extensionsPrefix as string) && !props.includes(k) ) ); } From 561ede258eaa3f0e36228ca3ea7cb7fee75b2352 Mon Sep 17 00:00:00 2001 From: Vlad Date: Fri, 7 Aug 2026 12:32:28 +0200 Subject: [PATCH 18/19] feat: add tests and fixtures for vendor extension counts in AsyncAPI 3 and OpenAPI 3 --- .../utils/__tests__/spec-extensions.test.ts | 553 ++---------------- .../e2e/stats/stats-extensions/asyncapi3.yaml | 19 + tests/e2e/stats/stats-extensions/openapi.yaml | 33 +- .../snapshot-asyncapi3-stylish.txt | 17 + .../stats/stats-extensions/snapshot-json.txt | 18 +- .../stats-extensions/snapshot-markdown.txt | 16 +- .../stats-extensions/snapshot-stylish.txt | 16 +- tests/e2e/stats/stats.test.ts | 9 + 8 files changed, 164 insertions(+), 517 deletions(-) create mode 100644 tests/e2e/stats/stats-extensions/asyncapi3.yaml create mode 100644 tests/e2e/stats/stats-extensions/snapshot-asyncapi3-stylish.txt diff --git a/packages/core/src/utils/__tests__/spec-extensions.test.ts b/packages/core/src/utils/__tests__/spec-extensions.test.ts index 9e8e2ef47c..98e52f0556 100644 --- a/packages/core/src/utils/__tests__/spec-extensions.test.ts +++ b/packages/core/src/utils/__tests__/spec-extensions.test.ts @@ -1,522 +1,83 @@ -import { outdent } from 'outdent'; +import type { SpecVendorExtensionsAccumulator } from '../../typings/common.js'; +import { collectSpecExtension } from '../spec-extensions.js'; -import { parseYamlToDocument } from '../../../__tests__/utils.js'; -import { detectSpec } from '../../detect-spec.js'; -import { getTypes } from '../../oas-types.js'; -import { BaseResolver, resolveDocument } from '../../resolve.js'; -import { StatsAsync2, StatsAsync3, StatsOAS } from '../../rules/other/stats.js'; -import { normalizeTypes } from '../../types/index.js'; -import type { - AsyncAPIStatsAccumulator, - OASStatsAccumulator, - SpecVendorExtensionsAccumulator, -} from '../../typings/common.js'; -import { normalizeVisitors } from '../../visitors.js'; -import { walkDocument } from '../../walk.js'; -import { ensureSpecExtensionDispatch } from '../spec-extensions.js'; - -function createOasStatsAccumulator(): OASStatsAccumulator { - return { - refs: { metric: 'References', total: 0, color: 'red', items: new Set() }, - externalDocs: { metric: 'External Documents', total: 0, color: 'magenta' }, - schemas: { metric: 'Schemas', total: 0, color: 'white' }, - parameters: { metric: 'Parameters', total: 0, color: 'yellow', items: new Set() }, - links: { metric: 'Links', total: 0, color: 'cyan', items: new Set() }, - pathItems: { metric: 'Path Items', total: 0, color: 'green' }, - 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' }, - }; -} - -function createAsyncStatsAccumulator(): AsyncAPIStatsAccumulator { - return { - refs: { metric: 'References', total: 0, color: 'red', items: new Set() }, - externalDocs: { metric: 'External Documents', total: 0, color: 'magenta' }, - schemas: { metric: 'Schemas', total: 0, color: 'white' }, - parameters: { metric: 'Parameters', total: 0, color: 'yellow', items: new Set() }, - 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' }, - }; -} - -async function walkStats(yaml: string): Promise { - const document = parseYamlToDocument(yaml, ''); - const specVersion = detectSpec(document.parsed); - const types = normalizeTypes(getTypes(specVersion)); - ensureSpecExtensionDispatch(types); - - let statsVisitor; - let statsAccumulator: OASStatsAccumulator | AsyncAPIStatsAccumulator; - if (specVersion === 'async2' || specVersion === 'async3') { - const asyncAccumulator = createAsyncStatsAccumulator(); - statsVisitor = - specVersion === 'async2' ? StatsAsync2(asyncAccumulator) : StatsAsync3(asyncAccumulator); - statsAccumulator = asyncAccumulator; - } else { - const oasAccumulator = createOasStatsAccumulator(); - statsVisitor = StatsOAS(oasAccumulator); - statsAccumulator = oasAccumulator; +function collectFrom(extensions: Record): SpecVendorExtensionsAccumulator { + const collected: SpecVendorExtensionsAccumulator = {}; + for (const [key, value] of Object.entries(extensions)) { + collectSpecExtension(collected, key, value); } - - const visitors = normalizeVisitors( - [{ severity: 'warn', ruleId: 'test', visitor: statsVisitor }], - types - ); - const resolvedRefMap = await resolveDocument({ - rootDocument: document, - rootType: types.Root, - externalRefResolver: new BaseResolver(), - }); - walkDocument({ - rootType: types.Root, - normalizedVisitors: visitors, - resolvedRefMap, - document, - ctx: { problems: [], specVersion, visitorsData: {} }, - }); - return statsAccumulator; -} - -async function collect(yaml: string): Promise { - return (await walkStats(yaml)).xExtensions.details ?? {}; + return collected; } const props = (acc: SpecVendorExtensionsAccumulator, name: string, prop: string) => [...(acc[name]?.props[prop] ?? [])].sort(); -describe('stats vendor extensions collection', () => { - it('should count every x- key, including extensions that have a declared type in core', async () => { - const acc = await collect(outdent` - openapi: 3.1.0 - info: - title: t - version: '1' - paths: - /a: - get: - operationId: a - x-codeSamples: - - lang: curl - source: curl https://example.com - x-badges: - - name: Beta - color: purple - responses: - '200': - description: ok - `); - - expect(Object.keys(acc)).toEqual(['x-badges', 'x-codeSamples']); - expect(acc['x-codeSamples']?.count).toBe(1); - expect(acc['x-badges']?.count).toBe(1); - }); - - it('should not dedupe repeated scalar values across different nodes', async () => { - const acc = await collect(outdent` - openapi: 3.1.0 - info: - title: t - version: '1' - paths: - /a: - get: - operationId: a - x-internal: true - responses: - '200': - description: ok - /b: - get: - operationId: b - x-internal: true - responses: - '200': - description: ok - `); - - expect(acc['x-internal']?.count).toBe(2); - }); - - it('should count an extension on a $ref-shared node once', async () => { - const acc = await collect(outdent` - openapi: 3.1.0 - info: - title: t - version: '1' - paths: - /a: - get: - operationId: a - parameters: - - $ref: '#/components/parameters/Shared' - responses: - '200': - description: ok - /b: - get: - operationId: b - parameters: - - $ref: '#/components/parameters/Shared' - responses: - '200': - description: ok - components: - parameters: - Shared: - name: p - in: query - x-hideReplay: true - schema: - type: string - `); - - expect(acc['x-hideReplay']?.count).toBe(1); - }); - - it('should count a sibling extension on a later $ref to an already-visited target', async () => { - const acc = await collect(outdent` - openapi: 3.1.0 - info: - title: t - version: '1' - paths: - /a: - get: - operationId: a - responses: - '200': - $ref: '#/components/responses/Shared' - /b: - get: - operationId: b - responses: - '200': - $ref: '#/components/responses/Shared' - x-second-ref-ext: true - components: - responses: - Shared: - description: ok - `); - - expect(acc['x-second-ref-ext']?.count).toBe(1); - }); - - it('should not descend into an extension value (no props-of-props)', async () => { - const acc = await collect(outdent` - openapi: 3.1.0 - info: - title: t - version: '1' - x-outer: - x-inner: 1 - `); - - expect(Object.keys(acc)).toEqual(['x-outer']); - expect(props(acc, 'x-outer', 'x-inner')).toEqual(['1']); - }); - - it('should count an extension on a map-typed node (Paths)', async () => { - const acc = await collect(outdent` - openapi: 3.1.0 - info: - title: t - version: '1' - paths: - x-paths-ext: true - /a: - get: - operationId: a - responses: - '200': - description: ok - `); - - expect(acc['x-paths-ext']?.count).toBe(1); - }); - - it('should keep webhook and tag metrics while counting legacy x-webhooks', async () => { - const stats = (await walkStats(outdent` - openapi: 3.0.0 - info: - title: t - version: '1' - paths: {} - x-webhooks: - newPet: - post: - tags: - - pets - responses: - '200': - description: ok - `)) as OASStatsAccumulator; - - expect(stats.webhooks.total).toBe(1); - expect(stats.tags.total).toBe(1); - expect(stats.xExtensions.total).toBe(1); - expect(stats.xExtensions.details?.['x-webhooks']?.count).toBe(1); - }); - - it('should keep operation and tag metrics while counting x-query', async () => { - const stats = (await walkStats(outdent` - openapi: 3.1.0 - info: - title: t - version: '1' - paths: - /a: - get: - operationId: a - responses: - '200': - description: ok - x-query: - operationId: q - tags: - - queries - responses: - '200': - description: ok - `)) as OASStatsAccumulator; +describe('stats vendor extensions value sampling', () => { + it('should keep short scalars but replace long strings with a length marker', () => { + const acc = collectFrom({ 'x-short': 'hello', 'x-long': 'x'.repeat(80) }); - expect(stats.operations.total).toBe(2); - expect(stats.tags.total).toBe(1); - expect(stats.xExtensions.details?.['x-query']?.count).toBe(1); + expect(props(acc, 'x-short', '$value')).toEqual(['hello']); + expect(props(acc, 'x-long', '$value')).toEqual(['']); }); - it('should not count map keys (schema/component names) that start with x-', async () => { - const acc = await collect(outdent` - openapi: 3.1.0 - info: - title: t - version: '1' - paths: - /a: - get: - operationId: a - responses: - '200': - description: ok - content: - application/json: - schema: - $ref: '#/components/schemas/Pet' - components: - schemas: - x-MySchema: - type: string - Pet: - type: object - properties: - x-trace-id: - type: string - `); + it('should collect the extension value under $value when it has no own props', () => { + const acc = collectFrom({ 'x-flag': true }); - // `x-MySchema` (component name) and `x-trace-id` (property name) are map keys, not extensions - expect(acc['x-MySchema']).toBeUndefined(); - expect(acc['x-trace-id']).toBeUndefined(); + expect(props(acc, 'x-flag', '$value')).toEqual(['true']); }); - it('should count an extension written next to a $ref', async () => { - const acc = await collect(outdent` - openapi: 3.1.0 - info: - title: t - version: '1' - paths: - /a: - get: - operationId: a - responses: - '200': - $ref: '#/components/responses/Shared' - x-sibling-ext: true - components: - responses: - Shared: - description: ok - `); - - expect(acc['x-sibling-ext']?.count).toBe(1); - }); - - describe('value collection (describe)', () => { - it('should keep short scalars but replace long strings with a length marker', async () => { - const long = 'x'.repeat(80); - const acc = await collect(outdent` - openapi: 3.1.0 - info: - title: t - version: '1' - x-short: hello - x-long: ${long} - `); - - expect(props(acc, 'x-short', '$value')).toEqual(['hello']); - expect(props(acc, 'x-long', '$value')).toEqual(['']); + it('should mark a $ref value as and an object or array as its type', () => { + const acc = collectFrom({ + 'x-codeSamples': [{ lang: 'curl', source: { $ref: '#/x' } }], + 'x-shapes': { nested: { a: 1 }, list: [1, 2, 3] }, }); - it('should mark a $ref value as and an object/array as its type', async () => { - const acc = await collect(outdent` - openapi: 3.1.0 - info: - title: t - version: '1' - paths: - /a: - get: - operationId: a - x-codeSamples: - - lang: curl - source: - $ref: '#/x' - responses: - '200': - description: ok - `); - - expect(props(acc, 'x-codeSamples', 'source')).toEqual(['']); - expect(props(acc, 'x-codeSamples', 'lang')).toEqual(['curl']); - }); - - it('should mask sensitive values by key and by value shape, keeping benign ones', async () => { - const acc = await collect(outdent` - openapi: 3.1.0 - info: - title: t - version: '1' - x-auth-token: benign-but-key-is-sensitive - x-gateway: - apiKey: abc123 - authorization: Basic abc - url: https://internal.corp/api - contact: jane.doe@corp.com - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - color: purple - `); - - expect(props(acc, 'x-auth-token', '$value')).toEqual(['']); - expect(props(acc, 'x-gateway', 'apiKey')).toEqual(['']); - expect(props(acc, 'x-gateway', 'authorization')).toEqual(['']); - expect(props(acc, 'x-gateway', 'url')).toEqual(['']); - expect(props(acc, 'x-gateway', 'contact')).toEqual(['']); - expect(props(acc, 'x-gateway', 'traceId')).toEqual(['']); - expect(props(acc, 'x-gateway', 'color')).toEqual(['purple']); - }); - - it('should collect the extension value under $value when it has no own props', async () => { - const acc = await collect(outdent` - openapi: 3.1.0 - info: - title: t - version: '1' - x-flag: true - `); - - expect(props(acc, 'x-flag', '$value')).toEqual(['true']); - }); + expect(props(acc, 'x-codeSamples', 'lang')).toEqual(['curl']); + expect(props(acc, 'x-codeSamples', 'source')).toEqual(['']); + expect(props(acc, 'x-shapes', 'nested')).toEqual(['']); + expect(props(acc, 'x-shapes', 'list')).toEqual(['']); }); - describe('cross-spec (AsyncAPI)', () => { - it('should collect extensions across AsyncAPI 2.x nodes', async () => { - const acc = await collect(outdent` - asyncapi: 2.6.0 - info: - title: t - version: '1' - x-info-ext: true - channels: - user/signedup: - x-badges: - - name: Beta - color: purple - subscribe: - x-op-ext: true - message: - x-msg-ext: a - payload: - type: object - `); - - expect(acc['x-info-ext']?.count).toBe(1); - expect(acc['x-badges']?.count).toBe(1); - expect(acc['x-op-ext']?.count).toBe(1); - expect(acc['x-msg-ext']?.count).toBe(1); + it('should mask sensitive values by key and by value shape, keeping benign ones', () => { + const acc = collectFrom({ + 'x-auth-token': 'benign-but-key-is-sensitive', + 'x-gateway': { + apiKey: 'abc123', + authorization: 'Basic abc', + url: 'https://internal.corp/api', + contact: 'jane.doe@corp.com', + traceId: '4bf92f3577b34da6a3ce929d0e0e4736', + color: 'purple', + }, }); - it('should collect extensions across AsyncAPI 3.x nodes', async () => { - const acc = await collect(outdent` - asyncapi: 3.0.0 - info: - title: t - version: '1' - channels: - userSignedup: - x-channel-ext: 1 - address: user/signedup - messages: - m: - x-msg-ext: a - payload: - type: object - operations: - onSignup: - x-op-ext: true - action: receive - channel: - $ref: '#/channels/userSignedup' - `); - - expect(acc['x-channel-ext']?.count).toBe(1); - expect(acc['x-msg-ext']?.count).toBe(1); - expect(acc['x-op-ext']?.count).toBe(1); - }); + expect(props(acc, 'x-auth-token', '$value')).toEqual(['']); + expect(props(acc, 'x-gateway', 'apiKey')).toEqual(['']); + expect(props(acc, 'x-gateway', 'authorization')).toEqual(['']); + expect(props(acc, 'x-gateway', 'url')).toEqual(['']); + expect(props(acc, 'x-gateway', 'contact')).toEqual(['']); + expect(props(acc, 'x-gateway', 'traceId')).toEqual(['']); + expect(props(acc, 'x-gateway', 'color')).toEqual(['purple']); }); - describe('bounding (caps)', () => { - it('should cap distinct values per prop at 20 and mark the overflow as ', async () => { - const badges = Array.from({ length: 25 }, (_, i) => ` - color: c${i}`).join('\n'); - const acc = await collect(outdent` - openapi: 3.1.0 - info: - title: t - version: '1' - paths: - /a: - get: - operationId: a - x-badges: - ${badges} - responses: - '200': - description: ok - `); + it('should cap distinct values per prop at 20 and mark the overflow as ', () => { + const badges = Array.from({ length: 25 }, (_, index) => ({ color: `c${index}` })); + const acc = collectFrom({ 'x-badges': badges }); - const values = acc['x-badges'].props.color; - expect(values.size).toBe(21); - expect(values.has('')).toBe(true); - expect(values.has('c0')).toBe(true); - }); + const values = acc['x-badges'].props.color; + expect(values.size).toBe(21); + expect(values.has('c0')).toBe(true); + expect(values.has('')).toBe(true); + }); - it('should cap distinct props per extension at 20 and fold the rest under ', async () => { - const keys = Array.from({ length: 25 }, (_, i) => ` k${i}: v${i}`).join('\n'); - const acc = await collect(outdent` - openapi: 3.1.0 - info: - title: t - version: '1' - x-metadata: - ${keys} - `); + it('should cap distinct props per extension at 20 and fold the rest under ', () => { + const metadata = Object.fromEntries( + Array.from({ length: 25 }, (_, index) => [`k${index}`, `v${index}`]) + ); + const acc = collectFrom({ 'x-metadata': metadata }); - const propNames = Object.keys(acc['x-metadata'].props); - expect(propNames).toHaveLength(21); - expect(propNames).toContain(''); - }); + const propNames = Object.keys(acc['x-metadata'].props); + expect(propNames).toHaveLength(21); + expect(propNames).toContain(''); }); }); diff --git a/tests/e2e/stats/stats-extensions/asyncapi3.yaml b/tests/e2e/stats/stats-extensions/asyncapi3.yaml new file mode 100644 index 0000000000..0b4f2f20e5 --- /dev/null +++ b/tests/e2e/stats/stats-extensions/asyncapi3.yaml @@ -0,0 +1,19 @@ +asyncapi: 3.0.0 +info: + title: AsyncAPI 3 vendor extensions fixture + version: '1.0.0' +channels: + userSignedup: + x-channel-ext: 1 + address: user/signedup + messages: + userSignedUp: + x-msg-ext: a + payload: + type: object +operations: + onSignup: + x-op-ext: true + action: receive + channel: + $ref: '#/channels/userSignedup' diff --git a/tests/e2e/stats/stats-extensions/openapi.yaml b/tests/e2e/stats/stats-extensions/openapi.yaml index d42dcb5198..3795a494f1 100644 --- a/tests/e2e/stats/stats-extensions/openapi.yaml +++ b/tests/e2e/stats/stats-extensions/openapi.yaml @@ -1,10 +1,20 @@ -openapi: 3.1.0 +openapi: 3.0.0 info: title: Vendor extensions fixture version: '1.0' x-metadata: department: Platform team: Docs + x-outer: + x-inner: 1 +x-webhooks: + newPet: + post: + tags: + - webhook-tag + responses: + '200': + description: ok paths: x-paths-ext: true /a: @@ -22,6 +32,13 @@ paths: x-internal: true parameters: - $ref: '#/components/parameters/Shared' + responses: + '200': + $ref: '#/components/responses/SharedResponse' + x-query: + operationId: queryA + tags: + - query-tag responses: '200': description: ok @@ -36,7 +53,8 @@ paths: - $ref: '#/components/parameters/Shared' responses: '200': - description: ok + $ref: '#/components/responses/SharedResponse' + x-sibling-ext: true components: parameters: Shared: @@ -45,3 +63,14 @@ components: x-hideReplay: true schema: type: string + responses: + SharedResponse: + description: ok + schemas: + x-MySchema: + type: string + Pet: + type: object + properties: + x-trace-id: + type: string diff --git a/tests/e2e/stats/stats-extensions/snapshot-asyncapi3-stylish.txt b/tests/e2e/stats/stats-extensions/snapshot-asyncapi3-stylish.txt new file mode 100644 index 0000000000..b34ca1531c --- /dev/null +++ b/tests/e2e/stats/stats-extensions/snapshot-asyncapi3-stylish.txt @@ -0,0 +1,17 @@ +🚗 References: 1 +📦 External Documents: 0 +📈 Schemas: 0 +👉 Parameters: 0 +📡 Channels: 1 +👷 Operations: 1 +🔖 Tags: 0 +🧩 Vendor Extensions: 3 + - x-channel-ext: 1 + - x-msg-ext: 1 + - x-op-ext: 1 + +Document: asyncapi3.yaml stats: + + +asyncapi3.yaml: stats processed in ms + diff --git a/tests/e2e/stats/stats-extensions/snapshot-json.txt b/tests/e2e/stats/stats-extensions/snapshot-json.txt index cec19513f3..de8a54e53c 100644 --- a/tests/e2e/stats/stats-extensions/snapshot-json.txt +++ b/tests/e2e/stats/stats-extensions/snapshot-json.txt @@ -1,7 +1,7 @@ { "refs": { "metric": "🚗 References", - "total": 1 + "total": 2 }, "externalDocs": { "metric": "📦 External Documents", @@ -9,7 +9,7 @@ }, "schemas": { "metric": "📈 Schemas", - "total": 0 + "total": 2 }, "parameters": { "metric": "👉 Parameters", @@ -25,26 +25,30 @@ }, "webhooks": { "metric": "🎣 Webhooks", - "total": 0 + "total": 1 }, "operations": { "metric": "👷 Operations", - "total": 2 + "total": 3 }, "tags": { "metric": "🔖 Tags", - "total": 0 + "total": 2 }, "xExtensions": { "metric": "🧩 Vendor Extensions", - "total": 6, + "total": 10, "counts": { "x-badges": 1, "x-codeSamples": 2, "x-hideReplay": 1, "x-internal": 2, "x-metadata": 1, - "x-paths-ext": 1 + "x-outer": 1, + "x-paths-ext": 1, + "x-query": 1, + "x-sibling-ext": 1, + "x-webhooks": 1 } } } diff --git a/tests/e2e/stats/stats-extensions/snapshot-markdown.txt b/tests/e2e/stats/stats-extensions/snapshot-markdown.txt index 2bcc70a386..2ac3fdcfda 100644 --- a/tests/e2e/stats/stats-extensions/snapshot-markdown.txt +++ b/tests/e2e/stats/stats-extensions/snapshot-markdown.txt @@ -1,15 +1,15 @@ | Feature | Count | | --- | --- | -| 🚗 References | 1 | +| 🚗 References | 2 | | 📦 External Documents | 0 | -| 📈 Schemas | 0 | +| 📈 Schemas | 2 | | 👉 Parameters | 1 | | 🔗 Links | 0 | | 🔀 Path Items | 2 | -| 🎣 Webhooks | 0 | -| 👷 Operations | 2 | -| 🔖 Tags | 0 | -| 🧩 Vendor Extensions | 6 | +| 🎣 Webhooks | 1 | +| 👷 Operations | 3 | +| 🔖 Tags | 2 | +| 🧩 Vendor Extensions | 10 | #### 🧩 Vendor Extensions | Extension | Count | @@ -19,7 +19,11 @@ | x-hideReplay | 1 | | x-internal | 2 | | x-metadata | 1 | +| x-outer | 1 | | x-paths-ext | 1 | +| x-query | 1 | +| x-sibling-ext | 1 | +| x-webhooks | 1 | Document: openapi.yaml stats: diff --git a/tests/e2e/stats/stats-extensions/snapshot-stylish.txt b/tests/e2e/stats/stats-extensions/snapshot-stylish.txt index 22cbd701fa..8b9e05b0f7 100644 --- a/tests/e2e/stats/stats-extensions/snapshot-stylish.txt +++ b/tests/e2e/stats/stats-extensions/snapshot-stylish.txt @@ -1,19 +1,23 @@ -🚗 References: 1 +🚗 References: 2 📦 External Documents: 0 -📈 Schemas: 0 +📈 Schemas: 2 👉 Parameters: 1 🔗 Links: 0 🔀 Path Items: 2 -🎣 Webhooks: 0 -👷 Operations: 2 -🔖 Tags: 0 -🧩 Vendor Extensions: 6 +🎣 Webhooks: 1 +👷 Operations: 3 +🔖 Tags: 2 +🧩 Vendor Extensions: 10 - x-badges: 1 - x-codeSamples: 2 - x-hideReplay: 1 - x-internal: 2 - x-metadata: 1 + - x-outer: 1 - x-paths-ext: 1 + - x-query: 1 + - x-sibling-ext: 1 + - x-webhooks: 1 Document: openapi.yaml stats: diff --git a/tests/e2e/stats/stats.test.ts b/tests/e2e/stats/stats.test.ts index 9723b27fac..5ac5c2e0b8 100644 --- a/tests/e2e/stats/stats.test.ts +++ b/tests/e2e/stats/stats.test.ts @@ -74,6 +74,15 @@ describe('stats', () => { ); }); + test('stats should report vendor extension counts for AsyncAPI 3 (stylish format)', async () => { + const testPath = join(folderPath, 'stats-extensions'); + const args = getParams(indexEntryPoint, ['stats', 'asyncapi3.yaml']); + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(testPath, 'snapshot-asyncapi3-stylish.txt') + ); + }); + test('stats should report vendor extension counts (Markdown format)', async () => { const testPath = join(folderPath, 'stats-extensions'); const args = getParams(indexEntryPoint, ['stats', 'openapi.yaml', '--format=markdown']); From 06d4e230c930a9897c7ae7819d97bf2ba2b9b360 Mon Sep 17 00:00:00 2001 From: Vlad Date: Fri, 7 Aug 2026 12:45:36 +0200 Subject: [PATCH 19/19] feat: update stats processing to correctly count 'x-query' extensions in OpenAPI specs --- packages/core/src/rules/other/stats.ts | 12 +++++++----- tests/e2e/stats/stats-extensions/openapi.yaml | 5 +++++ tests/e2e/stats/stats-extensions/snapshot-json.txt | 4 ++-- .../e2e/stats/stats-extensions/snapshot-markdown.txt | 4 ++-- .../e2e/stats/stats-extensions/snapshot-stylish.txt | 4 ++-- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/core/src/rules/other/stats.ts b/packages/core/src/rules/other/stats.ts index 5d7eeaa6a4..8d43699bb0 100644 --- a/packages/core/src/rules/other/stats.ts +++ b/packages/core/src/rules/other/stats.ts @@ -78,17 +78,19 @@ export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => { }, }, }, + Operation: { + enter(operation: Oas3Operation, ctx: UserContext) { + if (ctx.key === 'x-query') { + collectSpecExtension(extensions, 'x-query', operation); + } + }, + }, Paths: { PathItem: { leave() { statsAccumulator.pathItems.total++; }, Operation: { - enter(operation: Oas3Operation, ctx: UserContext) { - if (ctx.key === 'x-query') { - collectSpecExtension(extensions, 'x-query', operation); - } - }, leave(operation: Oas3Operation) { statsAccumulator.operations.total++; if (operation.tags) { diff --git a/tests/e2e/stats/stats-extensions/openapi.yaml b/tests/e2e/stats/stats-extensions/openapi.yaml index 3795a494f1..f09a8158b0 100644 --- a/tests/e2e/stats/stats-extensions/openapi.yaml +++ b/tests/e2e/stats/stats-extensions/openapi.yaml @@ -15,6 +15,11 @@ x-webhooks: responses: '200': description: ok + x-query: + operationId: webhookQuery + responses: + '200': + description: ok paths: x-paths-ext: true /a: diff --git a/tests/e2e/stats/stats-extensions/snapshot-json.txt b/tests/e2e/stats/stats-extensions/snapshot-json.txt index de8a54e53c..8f7bd0f241 100644 --- a/tests/e2e/stats/stats-extensions/snapshot-json.txt +++ b/tests/e2e/stats/stats-extensions/snapshot-json.txt @@ -25,7 +25,7 @@ }, "webhooks": { "metric": "🎣 Webhooks", - "total": 1 + "total": 2 }, "operations": { "metric": "👷 Operations", @@ -46,7 +46,7 @@ "x-metadata": 1, "x-outer": 1, "x-paths-ext": 1, - "x-query": 1, + "x-query": 2, "x-sibling-ext": 1, "x-webhooks": 1 } diff --git a/tests/e2e/stats/stats-extensions/snapshot-markdown.txt b/tests/e2e/stats/stats-extensions/snapshot-markdown.txt index 2ac3fdcfda..2f8912b6d2 100644 --- a/tests/e2e/stats/stats-extensions/snapshot-markdown.txt +++ b/tests/e2e/stats/stats-extensions/snapshot-markdown.txt @@ -6,7 +6,7 @@ | 👉 Parameters | 1 | | 🔗 Links | 0 | | 🔀 Path Items | 2 | -| 🎣 Webhooks | 1 | +| 🎣 Webhooks | 2 | | 👷 Operations | 3 | | 🔖 Tags | 2 | | 🧩 Vendor Extensions | 10 | @@ -21,7 +21,7 @@ | x-metadata | 1 | | x-outer | 1 | | x-paths-ext | 1 | -| x-query | 1 | +| x-query | 2 | | x-sibling-ext | 1 | | x-webhooks | 1 | diff --git a/tests/e2e/stats/stats-extensions/snapshot-stylish.txt b/tests/e2e/stats/stats-extensions/snapshot-stylish.txt index 8b9e05b0f7..8d1df42b2e 100644 --- a/tests/e2e/stats/stats-extensions/snapshot-stylish.txt +++ b/tests/e2e/stats/stats-extensions/snapshot-stylish.txt @@ -4,7 +4,7 @@ 👉 Parameters: 1 🔗 Links: 0 🔀 Path Items: 2 -🎣 Webhooks: 1 +🎣 Webhooks: 2 👷 Operations: 3 🔖 Tags: 2 🧩 Vendor Extensions: 10 @@ -15,7 +15,7 @@ - x-metadata: 1 - x-outer: 1 - x-paths-ext: 1 - - x-query: 1 + - x-query: 2 - x-sibling-ext: 1 - x-webhooks: 1