From 974233d4dfa1a849c565a39ef0b9f2909940a640 Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 5 Aug 2026 13:25:12 +0200 Subject: [PATCH 01/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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 From 280804f7fb8d155cf187d009968708faef6ac82b Mon Sep 17 00:00:00 2001 From: Vlad Date: Mon, 10 Aug 2026 09:13:14 +0200 Subject: [PATCH 20/27] feat: standardize indentation for vendor extensions in stats output --- docs/@v2/commands/stats.md | 12 +++++------ .../src/commands/stats/print-stats/stylish.ts | 2 +- .../snapshot-asyncapi-stylish.txt | 6 +++--- .../snapshot-asyncapi3-stylish.txt | 6 +++--- .../stats-extensions/snapshot-stylish.txt | 20 +++++++++---------- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/@v2/commands/stats.md b/docs/@v2/commands/stats.md index ad3e2116b0..13185b94f9 100644 --- a/docs/@v2/commands/stats.md +++ b/docs/@v2/commands/stats.md @@ -128,10 +128,10 @@ Document: museum.yaml stats: 👷 Operations: 8 🔖 Tags: 3 🧩 Vendor Extensions: 4 - - x-badges: 1 - - x-codeSamples: 2 - - x-internal: 2 - - x-metadata: 1 + - x-badges: 1 + - x-codeSamples: 2 + - x-internal: 2 + - x-metadata: 1 museum.yaml: stats processed in 4ms @@ -149,8 +149,8 @@ Document: asyncapi.yaml stats: 👷 Operations: 1 🔖 Tags: 2 🧩 Vendor Extensions: 2 - - x-internal: 1 - - x-metadata: 1 + - x-internal: 1 + - x-metadata: 1 asyncapi.yaml: stats processed in 4ms diff --git a/packages/cli/src/commands/stats/print-stats/stylish.ts b/packages/cli/src/commands/stats/print-stats/stylish.ts index 9569e00852..86c3d19388 100644 --- a/packages/cli/src/commands/stats/print-stats/stylish.ts +++ b/packages/cli/src/commands/stats/print-stats/stylish.ts @@ -14,7 +14,7 @@ export function printStatsStylish( const colorFn = colors[color as keyof typeof colors] as (text: string) => string; logger.output(colorFn(`${metric}: ${total} \n`)); for (const [name, { count }] of Object.entries(details || {})) { - logger.output(colorFn(` - ${name}: ${count} \n`)); + logger.output(colorFn(` - ${name}: ${count} \n`)); } } } diff --git a/tests/e2e/stats/stats-extensions/snapshot-asyncapi-stylish.txt b/tests/e2e/stats/stats-extensions/snapshot-asyncapi-stylish.txt index 26c75c244e..98f16094a4 100644 --- a/tests/e2e/stats/stats-extensions/snapshot-asyncapi-stylish.txt +++ b/tests/e2e/stats/stats-extensions/snapshot-asyncapi-stylish.txt @@ -6,9 +6,9 @@ 👷 Operations: 1 🔖 Tags: 0 🧩 Vendor Extensions: 3 - - x-channel-ext: 1 - - x-internal: 2 - - x-metadata: 1 + - x-channel-ext: 1 + - x-internal: 2 + - x-metadata: 1 Document: asyncapi.yaml stats: diff --git a/tests/e2e/stats/stats-extensions/snapshot-asyncapi3-stylish.txt b/tests/e2e/stats/stats-extensions/snapshot-asyncapi3-stylish.txt index b34ca1531c..3df9cf9d42 100644 --- a/tests/e2e/stats/stats-extensions/snapshot-asyncapi3-stylish.txt +++ b/tests/e2e/stats/stats-extensions/snapshot-asyncapi3-stylish.txt @@ -6,9 +6,9 @@ 👷 Operations: 1 🔖 Tags: 0 🧩 Vendor Extensions: 3 - - x-channel-ext: 1 - - x-msg-ext: 1 - - x-op-ext: 1 + - x-channel-ext: 1 + - x-msg-ext: 1 + - x-op-ext: 1 Document: asyncapi3.yaml stats: diff --git a/tests/e2e/stats/stats-extensions/snapshot-stylish.txt b/tests/e2e/stats/stats-extensions/snapshot-stylish.txt index 8d1df42b2e..dabad5937a 100644 --- a/tests/e2e/stats/stats-extensions/snapshot-stylish.txt +++ b/tests/e2e/stats/stats-extensions/snapshot-stylish.txt @@ -8,16 +8,16 @@ 👷 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: 2 - - x-sibling-ext: 1 - - x-webhooks: 1 + - x-badges: 1 + - x-codeSamples: 2 + - x-hideReplay: 1 + - x-internal: 2 + - x-metadata: 1 + - x-outer: 1 + - x-paths-ext: 1 + - x-query: 2 + - x-sibling-ext: 1 + - x-webhooks: 1 Document: openapi.yaml stats: From ef28db32e9774bd5d8291bde1940b1493a5e76b2 Mon Sep 17 00:00:00 2001 From: Vlad Date: Mon, 10 Aug 2026 12:14:53 +0200 Subject: [PATCH 21/27] feat: fix stats command to accurately report parameters for AsyncAPI 2.x and 3.x --- .changeset/olive-donkeys-shave.md | 6 ++++++ packages/core/src/rules/other/stats.ts | 12 ++++-------- tests/e2e/stats/stats-async2-json/snapshot.txt | 2 +- tests/e2e/stats/stats-async2-stylish/snapshot.txt | 2 +- tests/e2e/stats/stats-async3-stylish/asyncapi3.yaml | 2 ++ tests/e2e/stats/stats-async3-stylish/snapshot.txt | 2 +- 6 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 .changeset/olive-donkeys-shave.md diff --git a/.changeset/olive-donkeys-shave.md b/.changeset/olive-donkeys-shave.md new file mode 100644 index 0000000000..a88e4dffe6 --- /dev/null +++ b/.changeset/olive-donkeys-shave.md @@ -0,0 +1,6 @@ +--- +'@redocly/openapi-core': patch +'@redocly/cli': patch +--- + +Fixed the `stats` command always reporting `Parameters: 0` for AsyncAPI 2.x and 3.x descriptions. diff --git a/packages/core/src/rules/other/stats.ts b/packages/core/src/rules/other/stats.ts index 8d43699bb0..71ffb798ef 100644 --- a/packages/core/src/rules/other/stats.ts +++ b/packages/core/src/rules/other/stats.ts @@ -162,10 +162,8 @@ export const StatsAsync2 = (statsAccumulator: AsyncAPIStatsAccumulator) => { }, }, Parameter: { - leave(parameter: any) { - if (parameter.name) { - statsAccumulator.parameters.items!.add(parameter.name); - } + leave(_: unknown, { key }: UserContext) { + statsAccumulator.parameters.items!.add(key.toString()); }, }, }, @@ -215,10 +213,8 @@ export const StatsAsync3 = (statsAccumulator: AsyncAPIStatsAccumulator) => { statsAccumulator.channels.total++; }, Parameter: { - leave(parameter: any) { - if (parameter.name) { - statsAccumulator.parameters.items!.add(parameter.name); - } + leave(_: unknown, { key }: UserContext) { + statsAccumulator.parameters.items!.add(key.toString()); }, }, }, diff --git a/tests/e2e/stats/stats-async2-json/snapshot.txt b/tests/e2e/stats/stats-async2-json/snapshot.txt index b67d06620b..0148a97e29 100644 --- a/tests/e2e/stats/stats-async2-json/snapshot.txt +++ b/tests/e2e/stats/stats-async2-json/snapshot.txt @@ -13,7 +13,7 @@ }, "parameters": { "metric": "👉 Parameters", - "total": 0 + "total": 1 }, "channels": { "metric": "📡 Channels", diff --git a/tests/e2e/stats/stats-async2-stylish/snapshot.txt b/tests/e2e/stats/stats-async2-stylish/snapshot.txt index 77d9d37399..9b9dbb434e 100644 --- a/tests/e2e/stats/stats-async2-stylish/snapshot.txt +++ b/tests/e2e/stats/stats-async2-stylish/snapshot.txt @@ -1,7 +1,7 @@ 🚗 References: 2 📦 External Documents: 1 📈 Schemas: 1 -👉 Parameters: 0 +👉 Parameters: 1 📡 Channels: 1 👷 Operations: 1 🔖 Tags: 2 diff --git a/tests/e2e/stats/stats-async3-stylish/asyncapi3.yaml b/tests/e2e/stats/stats-async3-stylish/asyncapi3.yaml index d6fdc42a4e..7a61177a50 100644 --- a/tests/e2e/stats/stats-async3-stylish/asyncapi3.yaml +++ b/tests/e2e/stats/stats-async3-stylish/asyncapi3.yaml @@ -15,6 +15,8 @@ channels: parameters: userId: description: User ID + orgId: + description: Organization ID messages: UserSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/tests/e2e/stats/stats-async3-stylish/snapshot.txt b/tests/e2e/stats/stats-async3-stylish/snapshot.txt index dd4ff058cc..6f45b71bfa 100644 --- a/tests/e2e/stats/stats-async3-stylish/snapshot.txt +++ b/tests/e2e/stats/stats-async3-stylish/snapshot.txt @@ -1,7 +1,7 @@ 🚗 References: 4 📦 External Documents: 0 📈 Schemas: 1 -👉 Parameters: 0 +👉 Parameters: 2 📡 Channels: 1 👷 Operations: 1 🔖 Tags: 2 From bbedead2c1643e3f73b848e1fbc60dcc96cdf13e Mon Sep 17 00:00:00 2001 From: Vlad Date: Tue, 11 Aug 2026 14:46:18 +0200 Subject: [PATCH 22/27] feat: enhance stats output for vendor extensions by adding new extensions and updating counts --- tests/e2e/stats/stats-extensions/openapi.yaml | 8 ++++++++ tests/e2e/stats/stats-extensions/snapshot-json.txt | 5 ++++- tests/e2e/stats/stats-extensions/snapshot-markdown.txt | 5 ++++- tests/e2e/stats/stats-extensions/snapshot-stylish.txt | 5 ++++- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/e2e/stats/stats-extensions/openapi.yaml b/tests/e2e/stats/stats-extensions/openapi.yaml index f09a8158b0..4f0e5b87fe 100644 --- a/tests/e2e/stats/stats-extensions/openapi.yaml +++ b/tests/e2e/stats/stats-extensions/openapi.yaml @@ -7,6 +7,14 @@ info: team: Docs x-outer: x-inner: 1 +x-tagGroups: + - name: Core + tags: + - group-tag-name +x-ignoredHeaderParameters: + - X-Trace-Id +x-servers: + - url: https://legacy.example.com x-webhooks: newPet: post: diff --git a/tests/e2e/stats/stats-extensions/snapshot-json.txt b/tests/e2e/stats/stats-extensions/snapshot-json.txt index 8f7bd0f241..08db2f358c 100644 --- a/tests/e2e/stats/stats-extensions/snapshot-json.txt +++ b/tests/e2e/stats/stats-extensions/snapshot-json.txt @@ -37,17 +37,20 @@ }, "xExtensions": { "metric": "🧩 Vendor Extensions", - "total": 10, + "total": 13, "counts": { "x-badges": 1, "x-codeSamples": 2, "x-hideReplay": 1, + "x-ignoredHeaderParameters": 1, "x-internal": 2, "x-metadata": 1, "x-outer": 1, "x-paths-ext": 1, "x-query": 2, + "x-servers": 1, "x-sibling-ext": 1, + "x-tagGroups": 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 2f8912b6d2..4b57e835fc 100644 --- a/tests/e2e/stats/stats-extensions/snapshot-markdown.txt +++ b/tests/e2e/stats/stats-extensions/snapshot-markdown.txt @@ -9,7 +9,7 @@ | 🎣 Webhooks | 2 | | 👷 Operations | 3 | | 🔖 Tags | 2 | -| 🧩 Vendor Extensions | 10 | +| 🧩 Vendor Extensions | 13 | #### 🧩 Vendor Extensions | Extension | Count | @@ -17,12 +17,15 @@ | x-badges | 1 | | x-codeSamples | 2 | | x-hideReplay | 1 | +| x-ignoredHeaderParameters | 1 | | x-internal | 2 | | x-metadata | 1 | | x-outer | 1 | | x-paths-ext | 1 | | x-query | 2 | +| x-servers | 1 | | x-sibling-ext | 1 | +| x-tagGroups | 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 dabad5937a..a46ef5ff3b 100644 --- a/tests/e2e/stats/stats-extensions/snapshot-stylish.txt +++ b/tests/e2e/stats/stats-extensions/snapshot-stylish.txt @@ -7,16 +7,19 @@ 🎣 Webhooks: 2 👷 Operations: 3 🔖 Tags: 2 -🧩 Vendor Extensions: 10 +🧩 Vendor Extensions: 13 - x-badges: 1 - x-codeSamples: 2 - x-hideReplay: 1 + - x-ignoredHeaderParameters: 1 - x-internal: 2 - x-metadata: 1 - x-outer: 1 - x-paths-ext: 1 - x-query: 2 + - x-servers: 1 - x-sibling-ext: 1 + - x-tagGroups: 1 - x-webhooks: 1 Document: openapi.yaml stats: From 66b983cbd9f333eb516a2e27e9d49ec91dc73412 Mon Sep 17 00:00:00 2001 From: Vlad Date: Tue, 11 Aug 2026 14:46:29 +0200 Subject: [PATCH 23/27] chore: update changeset --- .changeset/olive-donkeys-shave.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/olive-donkeys-shave.md b/.changeset/olive-donkeys-shave.md index a88e4dffe6..0abc20ff82 100644 --- a/.changeset/olive-donkeys-shave.md +++ b/.changeset/olive-donkeys-shave.md @@ -3,4 +3,4 @@ '@redocly/cli': patch --- -Fixed the `stats` command always reporting `Parameters: 0` for AsyncAPI 2.x and 3.x descriptions. +Fixed the `stats` command reporting wrong parameter count for AsyncAPI descriptions. From 141c77c568e69fe3d296b0de7b8d2e51f1bf28e2 Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 12 Aug 2026 15:03:59 +0200 Subject: [PATCH 24/27] feat: add tests for ensureSpecExtensionDispatch functionality --- .../utils/__tests__/spec-extensions.test.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/__tests__/spec-extensions.test.ts b/packages/core/src/utils/__tests__/spec-extensions.test.ts index 98e52f0556..021e62f379 100644 --- a/packages/core/src/utils/__tests__/spec-extensions.test.ts +++ b/packages/core/src/utils/__tests__/spec-extensions.test.ts @@ -1,5 +1,7 @@ +import { getTypes } from '../../oas-types.js'; +import { normalizeTypes, SpecExtension } from '../../types/index.js'; import type { SpecVendorExtensionsAccumulator } from '../../typings/common.js'; -import { collectSpecExtension } from '../spec-extensions.js'; +import { collectSpecExtension, ensureSpecExtensionDispatch } from '../spec-extensions.js'; function collectFrom(extensions: Record): SpecVendorExtensionsAccumulator { const collected: SpecVendorExtensionsAccumulator = {}; @@ -12,6 +14,32 @@ function collectFrom(extensions: Record): SpecVendorExtensionsA const props = (acc: SpecVendorExtensionsAccumulator, name: string, prop: string) => [...(acc[name]?.props[prop] ?? [])].sort(); +describe('ensureSpecExtensionDispatch', () => { + it('should free typed x- keys for dispatch while keeping structural ones typed', () => { + const types = normalizeTypes(getTypes('oas3_0')); + + ensureSpecExtensionDispatch(types); + + expect(types.Paths.extensionsPrefix).toBe('x-'); + expect(types.Operation.properties['x-codeSamples']).toBeUndefined(); + expect(types.Root.properties['x-webhooks']).toBeDefined(); + expect(types.PathItem.properties['x-query']).toBeDefined(); + }); + + it('should route x- keys past an untyped catch-all, leaving other keys to it', () => { + const types = normalizeTypes(getTypes('async2')); + + ensureSpecExtensionDispatch(types); + + const resolveEntry = types.Message.additionalProperties as ( + value: unknown, + key: string + ) => unknown; + expect(resolveEntry({}, 'x-custom')).toBe(SpecExtension); + expect(resolveEntry({}, 'contentType')).not.toBe(SpecExtension); + }); +}); + 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) }); From c97e6fb9a7ae22f98f5c2bc924e7bab0e0df8051 Mon Sep 17 00:00:00 2001 From: Vlad Date: Thu, 13 Aug 2026 13:01:28 +0200 Subject: [PATCH 25/27] chore: enhance vendor extension handling and statistics collection --- packages/cli/src/commands/stats/index.ts | 2 - .../src/commands/stats/print-stats/json.ts | 16 +- .../commands/stats/print-stats/markdown.ts | 13 +- .../src/commands/stats/print-stats/stylish.ts | 8 +- .../stats/visitor-and-accumulator-resolver.ts | 4 +- .../__snapshots__/redocly-yaml.test.ts.snap | 1 + packages/core/src/__tests__/walk.test.ts | 202 +++++++++++++++++- packages/core/src/index.ts | 2 - packages/core/src/resolve.ts | 14 +- packages/core/src/rules/other/stats.ts | 60 ++---- packages/core/src/types/asyncapi-bindings.ts | 4 + packages/core/src/types/asyncapi2.ts | 22 ++ packages/core/src/types/asyncapi3.ts | 16 ++ packages/core/src/types/index.ts | 2 +- .../src/types/json-schema-draft7.shared.ts | 1 + packages/core/src/types/oas2.ts | 11 +- packages/core/src/types/oas3.ts | 3 + packages/core/src/types/oas3_2.ts | 1 + packages/core/src/typings/common.ts | 6 +- .../utils/__tests__/spec-extensions.test.ts | 111 ---------- packages/core/src/utils/spec-extensions.ts | 95 -------- packages/core/src/walk.ts | 30 +-- .../e2e/stats/stats-extensions/asyncapi.yaml | 5 + .../snapshot-asyncapi-stylish.txt | 6 +- 24 files changed, 332 insertions(+), 303 deletions(-) delete mode 100644 packages/core/src/utils/__tests__/spec-extensions.test.ts delete mode 100644 packages/core/src/utils/spec-extensions.ts diff --git a/packages/cli/src/commands/stats/index.ts b/packages/cli/src/commands/stats/index.ts index f4b910a99e..071a828574 100644 --- a/packages/cli/src/commands/stats/index.ts +++ b/packages/cli/src/commands/stats/index.ts @@ -7,7 +7,6 @@ import { normalizeVisitors, walkDocument, bundle, - ensureSpecExtensionDispatch, type WalkContext, type OutputFormat, } from '@redocly/openapi-core'; @@ -31,7 +30,6 @@ 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/cli/src/commands/stats/print-stats/json.ts b/packages/cli/src/commands/stats/print-stats/json.ts index 22ce0e94ab..f4137e2625 100644 --- a/packages/cli/src/commands/stats/print-stats/json.ts +++ b/packages/cli/src/commands/stats/print-stats/json.ts @@ -5,16 +5,12 @@ import { } from '@redocly/openapi-core'; export function printStatsJson(statsAccumulator: OASStatsAccumulator | AsyncAPIStatsAccumulator) { - const json: any = {}; - for (const key of Object.keys(statsAccumulator)) { - 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]) - ); - } - } + const json = Object.fromEntries( + Object.entries(statsAccumulator).map(([key, { metric, total, counts }]) => [ + key, + { metric, total, counts }, + ]) + ); 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 84663c9652..f2068453fc 100644 --- a/packages/cli/src/commands/stats/print-stats/markdown.ts +++ b/packages/cli/src/commands/stats/print-stats/markdown.ts @@ -9,14 +9,13 @@ export function printStatsMarkdown( ) { let output = '| Feature | Count |\n| --- | --- |\n'; const breakdowns: string[] = []; - for (const key of Object.keys(statsAccumulator)) { - const stat = statsAccumulator[key as keyof typeof statsAccumulator]; - output += '| ' + stat.metric + ' | ' + stat.total + ' |\n'; - const details = Object.entries(stat.details || {}); - if (details.length) { + for (const { metric, total, counts } of Object.values(statsAccumulator)) { + output += `| ${metric} | ${total} |\n`; + const countEntries = Object.entries(counts ?? {}); + if (countEntries.length) { breakdowns.push( - `\n#### ${stat.metric}\n| Extension | Count |\n| --- | --- |\n` + - details.map(([name, { count }]) => `| ${name} | ${count} |`).join('\n') + + `\n#### ${metric}\n| Extension | Count |\n| --- | --- |\n` + + countEntries.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 86c3d19388..034d47fe19 100644 --- a/packages/cli/src/commands/stats/print-stats/stylish.ts +++ b/packages/cli/src/commands/stats/print-stats/stylish.ts @@ -8,12 +8,10 @@ import * as colors from 'colorette'; export function printStatsStylish( statsAccumulator: OASStatsAccumulator | AsyncAPIStatsAccumulator ) { - for (const node in statsAccumulator) { - const stat = statsAccumulator[node as keyof typeof statsAccumulator]; - const { metric, total, color, details } = stat; - const colorFn = colors[color as keyof typeof colors] as (text: string) => string; + for (const { metric, total, color, counts } of Object.values(statsAccumulator)) { + const colorFn = colors[color]; logger.output(colorFn(`${metric}: ${total} \n`)); - for (const [name, { count }] of Object.entries(details || {})) { + 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 480b7385e0..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,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' }, + xExtensions: { metric: '🧩 Vendor Extensions', total: 0, color: 'cyan', counts: {} }, }; 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' }, + xExtensions: { metric: '🧩 Vendor Extensions', total: 0, color: 'cyan', counts: {} }, }; let statsVisitor, statsAccumulator; diff --git a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap index bc90eff282..61d9e0c6fe 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -1366,6 +1366,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "NamedParameters", "NamedSecuritySchemes", "SecurityScheme", + "Scopes", "XCodeSample", "XCodeSampleList", "XServerList", diff --git a/packages/core/src/__tests__/walk.test.ts b/packages/core/src/__tests__/walk.test.ts index 42c089806e..7ec8b4a454 100644 --- a/packages/core/src/__tests__/walk.test.ts +++ b/packages/core/src/__tests__/walk.test.ts @@ -5,7 +5,7 @@ import { outdent } from 'outdent'; import { parseYamlToDocument, replaceSourceWithRef } from '../../__tests__/utils.js'; import { createConfig } from '../config/index.js'; import { lintDocument } from '../lint.js'; -import { type Oas2RuleSet, type Oas3RuleSet } from '../oas-types.js'; +import { type Async2RuleSet, type Oas2RuleSet, type Oas3RuleSet } from '../oas-types.js'; import { BaseResolver, type Document } from '../resolve.js'; import { listOf } from '../types/index.js'; @@ -1831,11 +1831,211 @@ describe('type extensions', () => { 'leave ParameterList', 'leave hook test', 'leave XWebHooks', + 'enter SpecExtension', + 'leave SpecExtension', 'leave Root', ]); }); }); +describe('spec extensions dispatch', () => { + it('should dispatch a typed extension to SpecExtension visitors and keep its typed walk', async () => { + const calls: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + SpecExtension: { + enter: (_node: any, ctx: any) => calls.push(`extension ${ctx.key}`), + }, + XCodeSampleList: { + enter: () => calls.push('typed walk of x-codeSamples'), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + x-codeSamples: + - lang: curl + source: echo + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(calls).toEqual(['typed walk of x-codeSamples', 'extension x-codeSamples']); + }); + + it('should dispatch an x- key as SpecExtension even when additionalProperties matches it', async () => { + const calls: string[] = []; + + const testRuleSet: Async2RuleSet = { + test: () => ({ + SpecExtension: { + enter: (_node: any, ctx: any) => calls.push(`extension ${ctx.key}`), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + asyncapi: 2.6.0 + info: + title: t + version: '1' + channels: + user/signedup: + subscribe: + message: + x-msg-ext: hello + payload: + type: object + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { async2: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(calls).toEqual(['extension x-msg-ext']); + }); + + it('should dispatch an x- key inside Swagger 2.0 scopes, leaving scope names to the map', async () => { + const calls: string[] = []; + + const testRuleSet: Oas2RuleSet = { + test: () => ({ + SpecExtension: { + enter: (_node: any, ctx: any) => calls.push(`extension ${ctx.key}`), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + swagger: '2.0' + info: + title: t + version: '1' + paths: {} + securityDefinitions: + oauth: + type: oauth2 + flow: implicit + authorizationUrl: https://example.com/auth + scopes: + read: Read access + x-scopes-ext: internal note + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas2: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(calls).toEqual(['extension x-scopes-ext']); + }); + + it('should dispatch an x- key on a callback, leaving expression keys to the map', async () => { + const calls: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + SpecExtension: { + enter: (_node: any, ctx: any) => calls.push(`extension ${ctx.key}`), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + callbacks: + onEvent: + x-callback-ext: true + '{$request.body#/url}': + post: + responses: + '200': + description: ok + responses: + '200': + description: ok + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(calls).toEqual(['extension x-callback-ext']); + }); + + it('should visit every occurrence of extensions with equal scalar values', async () => { + const calls: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + SpecExtension: { + enter: (_node: any, ctx: any) => calls.push(ctx.location.pointer), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + x-internal: true + /dog: + get: + operationId: getDog + x-internal: true + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(calls).toEqual(['#/paths/~1pet/get/x-internal', '#/paths/~1dog/get/x-internal']); + }); +}); + describe('ignoreNextRules', () => { it('should correctly skip top level', async () => { const calls: string[] = []; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 69fe666653..0bab80a39d 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 { ensureSpecExtensionDispatch } from './utils/spec-extensions.js'; export { loadConfig, loadIgnoreConfig, @@ -143,5 +142,4 @@ export type { OASStatsAccumulator, AsyncAPIStatsAccumulator, StatsName, - SpecVendorExtensionsAccumulator, } from './typings/common.js'; diff --git a/packages/core/src/resolve.ts b/packages/core/src/resolve.ts index c3e142b2da..375ec11a8f 100644 --- a/packages/core/src/resolve.ts +++ b/packages/core/src/resolve.ts @@ -321,16 +321,14 @@ export async function resolveDocument(opts: { for (const propName of Object.keys(node)) { let propValue = node[propName]; let propType = getOwn(type.properties, propName); - if (propType === undefined) propType = type.additionalProperties; + if (propType === undefined) { + propType = + type.extensionsPrefix && propName.startsWith(type.extensionsPrefix) + ? SpecExtension + : type.additionalProperties; + } if (typeof propType === 'function') propType = propType(propValue, propName); if (propType === undefined) propType = unknownType; - if ( - type.extensionsPrefix && - propName.startsWith(type.extensionsPrefix) && - propType === unknownType - ) { - propType = SpecExtension; - } if (!isNamedType(propType) && propType?.directResolveAs) { propType = propType.directResolveAs; diff --git a/packages/core/src/rules/other/stats.ts b/packages/core/src/rules/other/stats.ts index 71ffb798ef..d0e173e8d8 100644 --- a/packages/core/src/rules/other/stats.ts +++ b/packages/core/src/rules/other/stats.ts @@ -1,8 +1,8 @@ import type { OASStatsAccumulator, AsyncAPIStatsAccumulator, - SpecVendorExtensionsAccumulator, StatsAccumulator, + StatsRow, } from '../../typings/common.js'; import type { Oas3Link, @@ -13,32 +13,32 @@ import type { OasRef, } from '../../typings/openapi.js'; import type { Oas2Parameter } from '../../typings/swagger.js'; -import { collectSpecExtension } from '../../utils/spec-extensions.js'; import type { UserContext } from '../../walk.js'; -function finalizeStats( - statsAccumulator: StatsAccumulator, - extensions: SpecVendorExtensionsAccumulator -) { +function countExtension(row: StatsRow, ctx: UserContext) { + const counts = (row.counts ??= {}); + const extensionName = ctx.key.toString(); + counts[extensionName] = (counts[extensionName] ?? 0) + 1; +} + +function finalizeStats(statsAccumulator: StatsAccumulator) { 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]]) - ); + const { xExtensions } = statsAccumulator; + const counts = xExtensions.counts ?? {}; + const extensionNames = Object.keys(counts).sort(); + xExtensions.total = extensionNames.length; + xExtensions.counts = Object.fromEntries(extensionNames.map((name) => [name, counts[name]])); } export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => { - const extensions: SpecVendorExtensionsAccumulator = {}; - return { SpecExtension: { - enter(node: unknown, ctx: UserContext) { - collectSpecExtension(extensions, ctx.key.toString(), node); + enter(_: unknown, ctx: UserContext) { + countExtension(statsAccumulator.xExtensions, ctx); }, }, ExternalDocs: { @@ -62,11 +62,6 @@ 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++; @@ -78,13 +73,6 @@ export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => { }, }, }, - Operation: { - enter(operation: Oas3Operation, ctx: UserContext) { - if (ctx.key === 'x-query') { - collectSpecExtension(extensions, 'x-query', operation); - } - }, - }, Paths: { PathItem: { leave() { @@ -116,19 +104,17 @@ export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => { }, Root: { leave() { - finalizeStats(statsAccumulator, extensions); + finalizeStats(statsAccumulator); }, }, }; }; export const StatsAsync2 = (statsAccumulator: AsyncAPIStatsAccumulator) => { - const extensions: SpecVendorExtensionsAccumulator = {}; - return { SpecExtension: { - enter(node: unknown, ctx: UserContext) { - collectSpecExtension(extensions, ctx.key.toString(), node); + enter(_: unknown, ctx: UserContext) { + countExtension(statsAccumulator.xExtensions, ctx); }, }, ExternalDocs: { @@ -177,19 +163,17 @@ export const StatsAsync2 = (statsAccumulator: AsyncAPIStatsAccumulator) => { }, Root: { leave() { - finalizeStats(statsAccumulator, extensions); + finalizeStats(statsAccumulator); }, }, }; }; export const StatsAsync3 = (statsAccumulator: AsyncAPIStatsAccumulator) => { - const extensions: SpecVendorExtensionsAccumulator = {}; - return { SpecExtension: { - enter(node: unknown, ctx: UserContext) { - collectSpecExtension(extensions, ctx.key.toString(), node); + enter(_: unknown, ctx: UserContext) { + countExtension(statsAccumulator.xExtensions, ctx); }, }, ExternalDocs: { @@ -240,7 +224,7 @@ export const StatsAsync3 = (statsAccumulator: AsyncAPIStatsAccumulator) => { }, Root: { leave() { - finalizeStats(statsAccumulator, extensions); + finalizeStats(statsAccumulator); }, }, }; diff --git a/packages/core/src/types/asyncapi-bindings.ts b/packages/core/src/types/asyncapi-bindings.ts index ce4a895cbd..382875d78c 100644 --- a/packages/core/src/types/asyncapi-bindings.ts +++ b/packages/core/src/types/asyncapi-bindings.ts @@ -849,6 +849,7 @@ const Ros2MessageBinding: NodeType = { }; export const ServerBindings: NodeType = { + extensionsPrefix: 'x-', properties: { http: 'HttpServerBinding', ws: 'WsServerBinding', @@ -872,6 +873,7 @@ export const ServerBindings: NodeType = { }; export const ChannelBindings: NodeType = { + extensionsPrefix: 'x-', properties: { http: 'HttpChannelBinding', ws: 'WsChannelBinding', @@ -898,6 +900,7 @@ export const ChannelBindings: NodeType = { }; export const OperationBindings: NodeType = { + extensionsPrefix: 'x-', properties: { http: 'HttpOperationBinding', ws: 'WsOperationBinding', @@ -921,6 +924,7 @@ export const OperationBindings: NodeType = { }; export const MessageBindings: NodeType = { + extensionsPrefix: 'x-', properties: { http: 'HttpMessageBinding', ws: 'WsMessageBinding', diff --git a/packages/core/src/types/asyncapi2.ts b/packages/core/src/types/asyncapi2.ts index be8f8fd179..fbd1d5b932 100644 --- a/packages/core/src/types/asyncapi2.ts +++ b/packages/core/src/types/asyncapi2.ts @@ -9,6 +9,7 @@ import { } from './json-schema-draft7.shared.js'; const Root: NodeType = { + extensionsPrefix: 'x-', properties: { asyncapi: null, // TODO: validate semver format and supported version info: 'Info', @@ -27,6 +28,7 @@ const Root: NodeType = { }; const Channel: NodeType = { + extensionsPrefix: 'x-', properties: { description: { type: 'string', @@ -53,6 +55,7 @@ const ChannelMap: NodeType = { }; export const Tag: NodeType = { + extensionsPrefix: 'x-', properties: { name: { type: 'string', description: 'REQUIRED. The name of the tag.' }, description: { @@ -67,6 +70,7 @@ export const Tag: NodeType = { }; export const ExternalDocs: NodeType = { + extensionsPrefix: 'x-', properties: { description: { type: 'string', @@ -91,6 +95,7 @@ const SecurityRequirement: NodeType = { }; const Server: NodeType = { + extensionsPrefix: 'x-', properties: { url: { type: 'string', @@ -130,6 +135,7 @@ export const ServerMap: NodeType = { }; export const ServerVariable: NodeType = { + extensionsPrefix: 'x-', properties: { enum: { type: 'array', @@ -158,6 +164,7 @@ export const ServerVariable: NodeType = { }; const Info: NodeType = { + extensionsPrefix: 'x-', properties: { title: { type: 'string', @@ -187,6 +194,7 @@ const Info: NodeType = { }; export const Contact: NodeType = { + extensionsPrefix: 'x-', properties: { name: { type: 'string', @@ -207,6 +215,7 @@ export const Contact: NodeType = { }; export const License: NodeType = { + extensionsPrefix: 'x-', properties: { name: { type: 'string', @@ -223,6 +232,7 @@ export const License: NodeType = { }; const Parameter: NodeType = { + extensionsPrefix: 'x-', properties: { description: { type: 'string', @@ -240,6 +250,7 @@ const Parameter: NodeType = { }; export const CorrelationId: NodeType = { + extensionsPrefix: 'x-', properties: { description: { type: 'string', @@ -258,6 +269,7 @@ export const CorrelationId: NodeType = { }; const Message: NodeType = { + extensionsPrefix: 'x-', properties: { messageId: { type: 'string', @@ -305,6 +317,7 @@ const Message: NodeType = { }; const OperationTrait: NodeType = { + extensionsPrefix: 'x-', properties: { tags: 'TagList', summary: { @@ -332,6 +345,7 @@ const OperationTrait: NodeType = { }; const MessageTrait: NodeType = { + extensionsPrefix: 'x-', properties: { messageId: { type: 'string', @@ -379,6 +393,7 @@ const MessageTrait: NodeType = { }; const Operation: NodeType = { + extensionsPrefix: 'x-', properties: { tags: 'TagList', summary: { @@ -408,6 +423,7 @@ const Operation: NodeType = { }; export const MessageExample: NodeType = { + extensionsPrefix: 'x-', properties: { payload: { isExample: true, @@ -431,6 +447,7 @@ export const MessageExample: NodeType = { }; const Components: NodeType = { + extensionsPrefix: 'x-', properties: { messages: 'NamedMessages', parameters: 'NamedParameters', @@ -450,6 +467,7 @@ const Components: NodeType = { }; const ImplicitFlow: NodeType = { + extensionsPrefix: 'x-', properties: { refreshUrl: { type: 'string' }, scopes: { type: 'object', additionalProperties: { type: 'string' } }, // TODO: validate scopes @@ -460,6 +478,7 @@ const ImplicitFlow: NodeType = { }; const PasswordFlow: NodeType = { + extensionsPrefix: 'x-', properties: { refreshUrl: { type: 'string' }, scopes: { type: 'object', additionalProperties: { type: 'string' } }, // TODO: validate scopes @@ -470,6 +489,7 @@ const PasswordFlow: NodeType = { }; const ClientCredentials: NodeType = { + extensionsPrefix: 'x-', properties: { refreshUrl: { type: 'string' }, scopes: { type: 'object', additionalProperties: { type: 'string' } }, // TODO: validate scopes @@ -480,6 +500,7 @@ const ClientCredentials: NodeType = { }; const AuthorizationCode: NodeType = { + extensionsPrefix: 'x-', properties: { refreshUrl: { type: 'string' }, authorizationUrl: { type: 'string' }, @@ -491,6 +512,7 @@ const AuthorizationCode: NodeType = { }; export const SecuritySchemeFlows: NodeType = { + extensionsPrefix: 'x-', properties: { implicit: 'ImplicitFlow', password: 'PasswordFlow', diff --git a/packages/core/src/types/asyncapi3.ts b/packages/core/src/types/asyncapi3.ts index 01ab538403..038cf740f8 100644 --- a/packages/core/src/types/asyncapi3.ts +++ b/packages/core/src/types/asyncapi3.ts @@ -21,6 +21,7 @@ import { } from './json-schema-draft7.shared.js'; const Root: NodeType = { + extensionsPrefix: 'x-', properties: { asyncapi: { type: 'string', @@ -47,6 +48,7 @@ const Root: NodeType = { }; const Channel: NodeType = { + extensionsPrefix: 'x-', properties: { address: { type: 'string', @@ -76,6 +78,7 @@ const Channel: NodeType = { }; const Server: NodeType = { + extensionsPrefix: 'x-', properties: { host: { type: 'string', @@ -113,6 +116,7 @@ const Server: NodeType = { }; const Info: NodeType = { + extensionsPrefix: 'x-', properties: { title: { type: 'string', @@ -144,6 +148,7 @@ const Info: NodeType = { }; const Parameter: NodeType = { + extensionsPrefix: 'x-', properties: { description: { type: 'string', @@ -175,6 +180,7 @@ const Parameter: NodeType = { }; const Message: NodeType = { + extensionsPrefix: 'x-', properties: { headers: 'Schema', payload: (value: Record) => { @@ -223,6 +229,7 @@ const Message: NodeType = { }; const OperationTrait: NodeType = { + extensionsPrefix: 'x-', properties: { tags: 'TagList', title: { @@ -249,6 +256,7 @@ const OperationTrait: NodeType = { }; const MessageTrait: NodeType = { + extensionsPrefix: 'x-', properties: { headers: (value: unknown) => { if (typeof value === 'function' || isPlainObject(value)) { @@ -295,6 +303,7 @@ const MessageTrait: NodeType = { }; const Operation: NodeType = { + extensionsPrefix: 'x-', properties: { action: { type: 'string', @@ -329,6 +338,7 @@ const Operation: NodeType = { }; const OperationReply: NodeType = { + extensionsPrefix: 'x-', properties: { channel: 'Channel', messages: 'MessageList', @@ -339,6 +349,7 @@ const OperationReply: NodeType = { }; const OperationReplyAddress: NodeType = { + extensionsPrefix: 'x-', properties: { location: { type: 'string', @@ -356,6 +367,7 @@ const OperationReplyAddress: NodeType = { }; const Components: NodeType = { + extensionsPrefix: 'x-', properties: { messages: 'NamedMessages', parameters: 'NamedParameters', @@ -382,6 +394,7 @@ const Components: NodeType = { }; const ImplicitFlow: NodeType = { + extensionsPrefix: 'x-', properties: { refreshUrl: { type: 'string' }, availableScopes: { type: 'object', additionalProperties: { type: 'string' } }, @@ -392,6 +405,7 @@ const ImplicitFlow: NodeType = { }; const PasswordFlow: NodeType = { + extensionsPrefix: 'x-', properties: { refreshUrl: { type: 'string' }, availableScopes: { type: 'object', additionalProperties: { type: 'string' } }, @@ -402,6 +416,7 @@ const PasswordFlow: NodeType = { }; const ClientCredentials: NodeType = { + extensionsPrefix: 'x-', properties: { refreshUrl: { type: 'string' }, availableScopes: { type: 'object', additionalProperties: { type: 'string' } }, @@ -412,6 +427,7 @@ const ClientCredentials: NodeType = { }; const AuthorizationCode: NodeType = { + extensionsPrefix: 'x-', properties: { refreshUrl: { type: 'string' }, authorizationUrl: { type: 'string' }, diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 7014ac1518..40324e440b 100755 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -68,7 +68,7 @@ export function listOf( export function mapOf( typeName: string, - opts: { description?: string; documentationLink?: string } = {} + opts: { description?: string; documentationLink?: string; extensionsPrefix?: string } = {} ) { return { name: `${typeName}Map`, diff --git a/packages/core/src/types/json-schema-draft7.shared.ts b/packages/core/src/types/json-schema-draft7.shared.ts index e704b095c1..6a81adf628 100644 --- a/packages/core/src/types/json-schema-draft7.shared.ts +++ b/packages/core/src/types/json-schema-draft7.shared.ts @@ -2,6 +2,7 @@ import { isMappingRef } from '../ref-utils.js'; import { listOf, type NodeType } from './index.js'; export const Schema: NodeType = { + extensionsPrefix: 'x-', properties: { $id: { type: 'string' }, $schema: { type: 'string' }, diff --git a/packages/core/src/types/oas2.ts b/packages/core/src/types/oas2.ts index 8401848d14..ce5c635c3d 100644 --- a/packages/core/src/types/oas2.ts +++ b/packages/core/src/types/oas2.ts @@ -71,6 +71,7 @@ const License: NodeType = { }; const Paths: NodeType = { + extensionsPrefix: 'x-', properties: {}, additionalProperties: (_value: unknown, key: string) => key.startsWith('/') ? 'PathItem' : undefined, @@ -215,6 +216,7 @@ const ParameterItems: NodeType = { }; const Responses: NodeType = { + extensionsPrefix: 'x-', properties: { default: 'Response', }, @@ -366,6 +368,12 @@ const Xml: NodeType = { extensionsPrefix: 'x-', }; +const Scopes: NodeType = { + extensionsPrefix: 'x-', + properties: {}, + additionalProperties: { type: 'string' }, +}; + const SecurityScheme: NodeType = { properties: { type: { enum: ['basic', 'apiKey', 'oauth2'] }, @@ -375,7 +383,7 @@ const SecurityScheme: NodeType = { flow: { enum: ['implicit', 'password', 'application', 'accessCode'] }, authorizationUrl: { type: 'string' }, tokenUrl: { type: 'string' }, - scopes: { type: 'object', additionalProperties: { type: 'string' } }, + scopes: 'Scopes', 'x-defaultClientId': { type: 'string' }, }, required(value) { @@ -472,6 +480,7 @@ export const Oas2Types = { NamedParameters: mapOf('Parameter'), NamedSecuritySchemes: mapOf('SecurityScheme'), SecurityScheme, + Scopes, XCodeSample, XCodeSampleList: listOf('XCodeSample'), XServerList: listOf('XServer'), diff --git a/packages/core/src/types/oas3.ts b/packages/core/src/types/oas3.ts index c23569005b..2d1efc5bef 100755 --- a/packages/core/src/types/oas3.ts +++ b/packages/core/src/types/oas3.ts @@ -213,6 +213,7 @@ const License: NodeType = { }; const Paths: NodeType = { + extensionsPrefix: 'x-', properties: {}, additionalProperties: (_value: unknown, key: string) => key.startsWith('/') ? 'PathItem' : undefined, @@ -515,6 +516,7 @@ const Header: NodeType = { }; const Responses: NodeType = { + extensionsPrefix: 'x-', properties: { default: 'Response' }, additionalProperties: (_v: unknown, key: string) => responseCodeRegexp.test(key) ? 'Response' : undefined, @@ -892,6 +894,7 @@ export const Oas3Types = { }), Operation, Callback: mapOf('PathItem', { + extensionsPrefix: 'x-', description: 'https://redocly.com/learn/openapi/openapi-visual-reference/callbacks#callback-object', }), diff --git a/packages/core/src/types/oas3_2.ts b/packages/core/src/types/oas3_2.ts index cb46a651a7..34839638ec 100755 --- a/packages/core/src/types/oas3_2.ts +++ b/packages/core/src/types/oas3_2.ts @@ -256,6 +256,7 @@ const Discriminator: NodeType = { }; const Components: NodeType = { + extensionsPrefix: 'x-', properties: { ...Oas3_1Types.Components.properties, mediaTypes: 'NamedMediaTypes', diff --git a/packages/core/src/typings/common.ts b/packages/core/src/typings/common.ts index de32a02997..78cfd23b48 100644 --- a/packages/core/src/typings/common.ts +++ b/packages/core/src/typings/common.ts @@ -3,7 +3,7 @@ export interface StatsRow { total: number; color: 'red' | 'yellow' | 'green' | 'white' | 'magenta' | 'cyan'; items?: Set; - details?: SpecVendorExtensionsAccumulator; + counts?: Record; } export type OASStatsName = @@ -32,7 +32,3 @@ 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; diff --git a/packages/core/src/utils/__tests__/spec-extensions.test.ts b/packages/core/src/utils/__tests__/spec-extensions.test.ts deleted file mode 100644 index 021e62f379..0000000000 --- a/packages/core/src/utils/__tests__/spec-extensions.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { getTypes } from '../../oas-types.js'; -import { normalizeTypes, SpecExtension } from '../../types/index.js'; -import type { SpecVendorExtensionsAccumulator } from '../../typings/common.js'; -import { collectSpecExtension, ensureSpecExtensionDispatch } from '../spec-extensions.js'; - -function collectFrom(extensions: Record): SpecVendorExtensionsAccumulator { - const collected: SpecVendorExtensionsAccumulator = {}; - for (const [key, value] of Object.entries(extensions)) { - collectSpecExtension(collected, key, value); - } - return collected; -} - -const props = (acc: SpecVendorExtensionsAccumulator, name: string, prop: string) => - [...(acc[name]?.props[prop] ?? [])].sort(); - -describe('ensureSpecExtensionDispatch', () => { - it('should free typed x- keys for dispatch while keeping structural ones typed', () => { - const types = normalizeTypes(getTypes('oas3_0')); - - ensureSpecExtensionDispatch(types); - - expect(types.Paths.extensionsPrefix).toBe('x-'); - expect(types.Operation.properties['x-codeSamples']).toBeUndefined(); - expect(types.Root.properties['x-webhooks']).toBeDefined(); - expect(types.PathItem.properties['x-query']).toBeDefined(); - }); - - it('should route x- keys past an untyped catch-all, leaving other keys to it', () => { - const types = normalizeTypes(getTypes('async2')); - - ensureSpecExtensionDispatch(types); - - const resolveEntry = types.Message.additionalProperties as ( - value: unknown, - key: string - ) => unknown; - expect(resolveEntry({}, 'x-custom')).toBe(SpecExtension); - expect(resolveEntry({}, 'contentType')).not.toBe(SpecExtension); - }); -}); - -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(props(acc, 'x-short', '$value')).toEqual(['hello']); - expect(props(acc, 'x-long', '$value')).toEqual(['']); - }); - - it('should collect the extension value under $value when it has no own props', () => { - const acc = collectFrom({ 'x-flag': true }); - - expect(props(acc, 'x-flag', '$value')).toEqual(['true']); - }); - - 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] }, - }); - - 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(['']); - }); - - 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', - }, - }); - - 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 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('c0')).toBe(true); - expect(values.has('')).toBe(true); - }); - - 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(''); - }); -}); diff --git a/packages/core/src/utils/spec-extensions.ts b/packages/core/src/utils/spec-extensions.ts deleted file mode 100644 index 6406bb2293..0000000000 --- a/packages/core/src/utils/spec-extensions.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { isNamedType, SpecExtension, type NormalizedNodeType } from '../types/index.js'; -import type { SpecVendorExtensionsAccumulator } from '../typings/common.js'; -import { isPlainObject } from './is-plain-object.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 = ''; -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/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 = /:\/\//; - -// Kept typed so other metrics still traverse their subtrees; the stats visitors count them explicitly. -const STRUCTURAL_EXTENSIONS = new Set(['x-webhooks', 'x-query']); - -// 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; - } - } -} - -export function collectSpecExtension( - accumulator: SpecVendorExtensionsAccumulator, - key: string, - value: unknown -) { - const entry = (accumulator[key] ??= { count: 0, props: {} }); - entry.count++; - for (const [prop, propValue] of getExtensionProps(value)) { - const sample = - SENSITIVE_KEY_REGEX.test(key) || SENSITIVE_KEY_REGEX.test(prop) - ? MASKED - : describe(propValue); - addSample(entry.props, prop, sample); - } -} - -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') { - 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 ``; - 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); -} diff --git a/packages/core/src/walk.ts b/packages/core/src/walk.ts index d5c5fb700d..763a979bc7 100644 --- a/packages/core/src/walk.ts +++ b/packages/core/src/walk.ts @@ -246,16 +246,14 @@ export function walkDocument(opts: { if (resolvedNode !== undefined && resolvedLocation && type.name !== 'scalar') { const walkProp = (propName: string, value: unknown, loc: Location, valueParent: unknown) => { let propType = getOwn(type.properties, propName); - if (propType === undefined) propType = type.additionalProperties; - if (typeof propType === 'function') propType = propType(value, propName); + const isExtensionKey = + type.extensionsPrefix !== undefined && propName.startsWith(type.extensionsPrefix); + const isDeclaredExtension = isExtensionKey && propType !== undefined; - if ( - propType === undefined && - type.extensionsPrefix && - propName.startsWith(type.extensionsPrefix) - ) { - propType = SpecExtension; + if (propType === undefined) { + propType = isExtensionKey ? SpecExtension : type.additionalProperties; } + if (typeof propType === 'function') propType = propType(value, propName); if (!isNamedType(propType) && propType?.directResolveAs) { propType = propType.directResolveAs; @@ -266,15 +264,21 @@ export function walkDocument(opts: { propType = { name: 'scalar', properties: {} }; } - if (!isNamedType(propType) || (propType.name === 'scalar' && !isRef(value))) { - return; + if (isNamedType(propType) && !(propType.name === 'scalar' && !isRef(value))) { + walkNode(value, propType, loc.child([propName]), valueParent, propName); + } + if (isDeclaredExtension && !isRef(value)) { + // a declared x- property keeps its typed walk above, but is an extension nonetheless + walkNode(value, SpecExtension, loc.child([propName]), valueParent, propName); } - - walkNode(value, propType, loc.child([propName]), valueParent, propName); }; currentLocation = resolvedLocation; - const seenKey = type === SpecExtension ? location.absolutePointer : resolvedNode; + // primitives have no identity to dedupe by — use their location; objects dedupe by identity + const seenKey = + isPlainObject(resolvedNode) || Array.isArray(resolvedNode) + ? resolvedNode + : location.absolutePointer; const isNodeSeen = seenNodesPerType[type.name]?.has?.(seenKey); let visitedBySome = false; diff --git a/tests/e2e/stats/stats-extensions/asyncapi.yaml b/tests/e2e/stats/stats-extensions/asyncapi.yaml index 7ee8cf285b..6411c8260a 100644 --- a/tests/e2e/stats/stats-extensions/asyncapi.yaml +++ b/tests/e2e/stats/stats-extensions/asyncapi.yaml @@ -4,6 +4,9 @@ info: version: '1.0.0' x-metadata: team: Docs +tags: + - name: signup + x-displayName: Sign up events channels: user/signedup: x-channel-ext: true @@ -14,6 +17,8 @@ channels: x-internal: true payload: type: object + x-enumDescriptions: + FREE: Free tier 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 index 98f16094a4..aa5bc01073 100644 --- a/tests/e2e/stats/stats-extensions/snapshot-asyncapi-stylish.txt +++ b/tests/e2e/stats/stats-extensions/snapshot-asyncapi-stylish.txt @@ -4,9 +4,11 @@ 👉 Parameters: 0 📡 Channels: 1 👷 Operations: 1 -🔖 Tags: 0 -🧩 Vendor Extensions: 3 +🔖 Tags: 1 +🧩 Vendor Extensions: 5 - x-channel-ext: 1 + - x-displayName: 1 + - x-enumDescriptions: 1 - x-internal: 2 - x-metadata: 1 From 34f0c0340728f9155fd6f2bc2fdc30ae1a6e386a Mon Sep 17 00:00:00 2001 From: Vlad Date: Thu, 13 Aug 2026 14:34:31 +0200 Subject: [PATCH 26/27] feat: enhance spec extension handling in walkDocument function --- packages/core/src/__tests__/walk.test.ts | 89 +++++++++++++++++++++++- packages/core/src/walk.ts | 31 ++++++--- 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/packages/core/src/__tests__/walk.test.ts b/packages/core/src/__tests__/walk.test.ts index 7ec8b4a454..16c8f226ee 100644 --- a/packages/core/src/__tests__/walk.test.ts +++ b/packages/core/src/__tests__/walk.test.ts @@ -1831,8 +1831,6 @@ describe('type extensions', () => { 'leave ParameterList', 'leave hook test', 'leave XWebHooks', - 'enter SpecExtension', - 'leave SpecExtension', 'leave Root', ]); }); @@ -1998,6 +1996,93 @@ describe('spec extensions dispatch', () => { expect(calls).toEqual(['extension x-callback-ext']); }); + it('should dispatch a declared extension whose value is a $ref, visiting the ref once', async () => { + const extensionCalls: string[] = []; + const refCalls: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + SpecExtension: { + enter: (_node: any, ctx: any) => extensionCalls.push(`extension ${ctx.key}`), + }, + ref: { + enter: (node: any) => refCalls.push(node.$ref), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + x-webhooks: + $ref: '#/components/x-hooks-source' + components: + x-hooks-source: + newPet: + post: + responses: + '200': + description: ok + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(extensionCalls).toContain('extension x-webhooks'); + expect(refCalls.filter((ref) => ref === '#/components/x-hooks-source')).toHaveLength(1); + }); + + it('should not re-visit $refs inside a declared extension subtree', async () => { + const refCalls: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + ref: { + enter: (node: any) => refCalls.push(node.$ref), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + x-webhooks: + newPet: + $ref: '#/components/x-hook-item' + components: + x-hook-item: + post: + responses: + '200': + description: ok + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(refCalls.filter((ref) => ref === '#/components/x-hook-item')).toHaveLength(1); + }); + it('should visit every occurrence of extensions with equal scalar values', async () => { const calls: string[] = []; diff --git a/packages/core/src/walk.ts b/packages/core/src/walk.ts index 763a979bc7..16c14635c6 100644 --- a/packages/core/src/walk.ts +++ b/packages/core/src/walk.ts @@ -172,7 +172,8 @@ export function walkDocument(opts: { type: NormalizedNodeType, location: Location, parent: any, - key: string | number + key: string | number, + isDeclaredExtension: boolean = false ) { const resolve: ResolveFn = (ref, from = currentLocation.source.absoluteRef) => { if (!isRef(ref)) return { location, node: ref }; @@ -254,6 +255,10 @@ export function walkDocument(opts: { propType = isExtensionKey ? SpecExtension : type.additionalProperties; } if (typeof propType === 'function') propType = propType(value, propName); + if (isDeclaredExtension && !isNamedType(propType)) { + // a declared extension with a plain schema gets no typed walk — visit it as SpecExtension + propType = SpecExtension; + } if (!isNamedType(propType) && propType?.directResolveAs) { propType = propType.directResolveAs; @@ -264,13 +269,18 @@ export function walkDocument(opts: { propType = { name: 'scalar', properties: {} }; } - if (isNamedType(propType) && !(propType.name === 'scalar' && !isRef(value))) { - walkNode(value, propType, loc.child([propName]), valueParent, propName); - } - if (isDeclaredExtension && !isRef(value)) { - // a declared x- property keeps its typed walk above, but is an extension nonetheless - walkNode(value, SpecExtension, loc.child([propName]), valueParent, propName); + if (!isNamedType(propType) || (propType.name === 'scalar' && !isRef(value))) { + return; } + + walkNode( + value, + propType, + loc.child([propName]), + valueParent, + propName, + isDeclaredExtension && propType !== SpecExtension + ); }; currentLocation = resolvedLocation; @@ -282,8 +292,13 @@ export function walkDocument(opts: { const isNodeSeen = seenNodesPerType[type.name]?.has?.(seenKey); let visitedBySome = false; - const currentEnterVisitors = + let currentEnterVisitors = combinedEnter[type.name] || anyEnter.concat(normalizedVisitors[type.name]?.enter || []); + if (isDeclaredExtension) { + currentEnterVisitors = currentEnterVisitors.concat( + normalizedVisitors.SpecExtension?.enter || [] + ); + } const activatedContexts: Array = []; const ignoreKey = `${currentLocation.absolutePointer}${currentLocation.pointer}`; From 33e26b1d0942edfd83c8c280fd9f25541a98cf63 Mon Sep 17 00:00:00 2001 From: Vlad Date: Thu, 13 Aug 2026 14:53:31 +0200 Subject: [PATCH 27/27] feat: enhance SpecExtension lifecycle handling in tests and walk function --- packages/core/src/__tests__/walk.test.ts | 186 ++++++++++++++++++++++- packages/core/src/walk.ts | 7 +- 2 files changed, 190 insertions(+), 3 deletions(-) diff --git a/packages/core/src/__tests__/walk.test.ts b/packages/core/src/__tests__/walk.test.ts index 16c8f226ee..555f365142 100644 --- a/packages/core/src/__tests__/walk.test.ts +++ b/packages/core/src/__tests__/walk.test.ts @@ -1843,7 +1843,8 @@ describe('spec extensions dispatch', () => { const testRuleSet: Oas3RuleSet = { test: () => ({ SpecExtension: { - enter: (_node: any, ctx: any) => calls.push(`extension ${ctx.key}`), + enter: (_node: any, ctx: any) => calls.push(`enter extension ${ctx.key}`), + leave: (_node: any, ctx: any) => calls.push(`leave extension ${ctx.key}`), }, XCodeSampleList: { enter: () => calls.push('typed walk of x-codeSamples'), @@ -1872,7 +1873,188 @@ describe('spec extensions dispatch', () => { }), }); - expect(calls).toEqual(['typed walk of x-codeSamples', 'extension x-codeSamples']); + expect(calls).toEqual([ + 'typed walk of x-codeSamples', + 'enter extension x-codeSamples', + 'leave extension x-codeSamples', + ]); + }); + + it('should give a full SpecExtension lifecycle to a declared extension with a plain schema', async () => { + const calls: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + SpecExtension: { + enter: (_node: any, ctx: any) => calls.push(`enter ${ctx.key}`), + leave: (_node: any, ctx: any) => calls.push(`leave ${ctx.key}`), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + x-hideTryItPanel: true + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(calls).toEqual(['enter x-hideTryItPanel', 'leave x-hideTryItPanel']); + }); + + it('should activate nested SpecExtension visitors for every extension kind', async () => { + const calls: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + Operation: { + SpecExtension: { + enter: (_node: any, ctx: any, parents: any) => + calls.push(`${parents.Operation.operationId} > ${ctx.key}`), + }, + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + x-codeSamples: + - lang: curl + source: echo + x-hideTryItPanel: true + x-unknown-ext: true + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(calls).toEqual(['get > x-codeSamples', 'get > x-hideTryItPanel', 'get > x-unknown-ext']); + }); + + it('should honor the skip hook of SpecExtension visitors for declared extensions', async () => { + const calls: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + SpecExtension: { + skip: (_node: any, key: any) => key === 'x-codeSamples', + enter: (_node: any, ctx: any) => calls.push(`enter ${ctx.key}`), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + x-codeSamples: + - lang: curl + source: echo + x-hideTryItPanel: true + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + expect(calls).toEqual(['enter x-hideTryItPanel']); + }); + + it('should visit a declared extension node once, not once per visitor kind', async () => { + const anyVisits: string[] = []; + + const testRuleSet: Oas3RuleSet = { + test: () => ({ + any: { + enter: (_node: any, ctx: any) => anyVisits.push(ctx.location.pointer), + }, + }), + }; + + await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + paths: + /pet: + get: + operationId: get + x-codeSamples: + - lang: curl + source: echo + `, + '' + ), + config: await createConfig({ + plugins: [{ id: 'test', rules: { oas3: testRuleSet } }], + rules: { 'test/test': 'error' }, + }), + }); + + const extensionVisits = anyVisits.filter( + (pointer) => pointer === '#/paths/~1pet/get/x-codeSamples' + ); + expect(extensionVisits).toHaveLength(1); + }); + + it('should keep struct validation for a typed extension with an invalid shape', async () => { + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document: parseYamlToDocument( + outdent` + openapi: 3.0.0 + info: + title: t + version: '1' + paths: + /pet: + get: + operationId: get + x-codeSamples: just a string + responses: + '200': + description: ok + `, + '' + ), + config: await createConfig({ rules: { struct: 'error' } }), + }); + + expect(results.map((problem) => problem.message)).toContain( + 'Expected type `XCodeSampleList` (array) but got `string`' + ); }); it('should dispatch an x- key as SpecExtension even when additionalProperties matches it', async () => { diff --git a/packages/core/src/walk.ts b/packages/core/src/walk.ts index 16c14635c6..cececde4a2 100644 --- a/packages/core/src/walk.ts +++ b/packages/core/src/walk.ts @@ -428,8 +428,13 @@ export function walkDocument(opts: { } } - const currentLeaveVisitors = + let currentLeaveVisitors = combinedLeave[type.name] || (normalizedVisitors[type.name]?.leave || []).concat(anyLeave); + if (isDeclaredExtension) { + currentLeaveVisitors = (normalizedVisitors.SpecExtension?.leave || []).concat( + currentLeaveVisitors + ); + } for (const context of activatedContexts.reverse()) { if (context.isSkippedLevel) {