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/.changeset/seven-waves-create.md b/.changeset/seven-waves-create.md new file mode 100644 index 0000000000..707caa43be --- /dev/null +++ b/.changeset/seven-waves-create.md @@ -0,0 +1,6 @@ +--- +'@redocly/openapi-core': minor +'@redocly/cli': minor +--- + +Added a Vendor Extensions metric to the `stats` command that reports how many distinct `x-` extensions a description file uses and how often each one occurs. diff --git a/docs/@v2/commands/stats.md b/docs/@v2/commands/stats.md index 5243a4b536..13185b94f9 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. @@ -123,6 +127,11 @@ Document: museum.yaml stats: 🎣 Webhooks: 0 👷 Operations: 8 🔖 Tags: 3 +🧩 Vendor Extensions: 4 + - x-badges: 1 + - x-codeSamples: 2 + - x-internal: 2 + - x-metadata: 1 museum.yaml: stats processed in 4ms @@ -139,6 +148,9 @@ Document: asyncapi.yaml stats: 📡 Channels: 1 👷 Operations: 1 🔖 Tags: 2 +🧩 Vendor Extensions: 2 + - x-internal: 1 + - x-metadata: 1 asyncapi.yaml: stats processed in 4ms @@ -187,6 +199,16 @@ The following is an example JSON output for an OpenAPI description: "tags": { "metric": "🔖 Tags", "total": 3 + }, + "xExtensions": { + "metric": "🧩 Vendor Extensions", + "total": 4, + "counts": { + "x-badges": 1, + "x-codeSamples": 2, + "x-internal": 2, + "x-metadata": 1 + } } } @@ -214,6 +236,15 @@ The following is an example source output for an OpenAPI description: | 🎣 Webhooks | 0 | | 👷 Operations | 8 | | 🔖 Tags | 3 | +| 🧩 Vendor Extensions | 4 | + +#### 🧩 Vendor Extensions +| Extension | Count | +| --- | --- | +| x-badges | 1 | +| x-codeSamples | 2 | +| x-internal | 2 | +| x-metadata | 1 | @@ -230,6 +261,16 @@ Here's the rendered example source output: | 🎣 Webhooks | 0 | | 👷 Operations | 8 | | 🔖 Tags | 3 | +| 🧩 Vendor Extensions | 4 | + +**🧩 Vendor Extensions** + +| Extension | Count | +| ------------- | ----- | +| x-badges | 1 | +| x-codeSamples | 2 | +| x-internal | 2 | +| x-metadata | 1 | For AsyncAPI descriptions, the table includes a `📡 Channels` row instead of the `🔗 Links`, `🔀 Path Items`, and `🎣 Webhooks` rows. 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/cli/src/commands/stats/print-stats/json.ts b/packages/cli/src/commands/stats/print-stats/json.ts index 98772fa22f..22ce0e94ab 100644 --- a/packages/cli/src/commands/stats/print-stats/json.ts +++ b/packages/cli/src/commands/stats/print-stats/json.ts @@ -7,11 +7,13 @@ import { export function printStatsJson(statsAccumulator: OASStatsAccumulator | AsyncAPIStatsAccumulator) { const json: any = {}; for (const key of Object.keys(statsAccumulator)) { - const stat = statsAccumulator[key as keyof typeof statsAccumulator]; - json[key] = { - metric: stat.metric, - total: stat.total, - }; + const { metric, total, details } = statsAccumulator[key as keyof typeof statsAccumulator]; + json[key] = { metric, total }; + if (details) { + json[key].counts = Object.fromEntries( + Object.entries(details).map(([name, { count }]) => [name, count]) + ); + } } logger.output(JSON.stringify(json, null, 2)); diff --git a/packages/cli/src/commands/stats/print-stats/markdown.ts b/packages/cli/src/commands/stats/print-stats/markdown.ts index a3158ac2ee..84663c9652 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 details = Object.entries(stat.details || {}); + if (details.length) { + breakdowns.push( + `\n#### ${stat.metric}\n| Extension | Count |\n| --- | --- |\n` + + details.map(([name, { count }]) => `| ${name} | ${count} |`).join('\n') + + '\n' + ); + } } - logger.output(output); + logger.output(output + breakdowns.join('')); } diff --git a/packages/cli/src/commands/stats/print-stats/stylish.ts b/packages/cli/src/commands/stats/print-stats/stylish.ts index 936550c7b2..86c3d19388 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, details } = stat; const colorFn = colors[color as keyof typeof colors] as (text: string) => string; logger.output(colorFn(`${metric}: ${total} \n`)); + for (const [name, { count }] of Object.entries(details || {})) { + logger.output(colorFn(` - ${name}: ${count} \n`)); + } } } 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..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,6 +20,7 @@ export function resolveStatsVisitorAndAccumulator(specVersion: SpecVersion) { webhooks: { metric: '🎣 Webhooks', total: 0, color: 'green' }, operations: { metric: '👷 Operations', total: 0, color: 'yellow' }, tags: { metric: '🔖 Tags', total: 0, color: 'white', items: new Set() }, + xExtensions: { metric: '🧩 Vendor Extensions', total: 0, color: 'cyan' }, }; const statsAccumulatorAsync: AsyncAPIStatsAccumulator = { refs: { metric: '🚗 References', total: 0, color: 'red', items: new Set() }, @@ -29,6 +30,7 @@ export function resolveStatsVisitorAndAccumulator(specVersion: SpecVersion) { channels: { metric: '📡 Channels', total: 0, color: 'green' }, operations: { metric: '👷 Operations', total: 0, color: 'yellow' }, tags: { metric: '🔖 Tags', total: 0, color: 'white', items: new Set() }, + xExtensions: { metric: '🧩 Vendor Extensions', total: 0, color: 'cyan' }, }; let statsVisitor, statsAccumulator; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0bab80a39d..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, @@ -142,4 +143,5 @@ export type { OASStatsAccumulator, AsyncAPIStatsAccumulator, StatsName, + SpecVendorExtensionsAccumulator, } from './typings/common.js'; diff --git a/packages/core/src/rules/other/stats.ts b/packages/core/src/rules/other/stats.ts index 23f17d94f2..71ffb798ef 100644 --- a/packages/core/src/rules/other/stats.ts +++ b/packages/core/src/rules/other/stats.ts @@ -1,4 +1,9 @@ -import type { OASStatsAccumulator, AsyncAPIStatsAccumulator } from '../../typings/common.js'; +import type { + OASStatsAccumulator, + AsyncAPIStatsAccumulator, + SpecVendorExtensionsAccumulator, + StatsAccumulator, +} from '../../typings/common.js'; import type { Oas3Link, Oas3Operation, @@ -8,9 +13,34 @@ import type { OasRef, } from '../../typings/openapi.js'; import type { Oas2Parameter } from '../../typings/swagger.js'; +import { collectSpecExtension } from '../../utils/spec-extensions.js'; +import type { UserContext } from '../../walk.js'; + +function finalizeStats( + statsAccumulator: StatsAccumulator, + extensions: SpecVendorExtensionsAccumulator +) { + for (const row of Object.values(statsAccumulator)) { + if (row.items) { + row.total = row.items.size; + } + } + const extensionNames = Object.keys(extensions).sort(); + statsAccumulator.xExtensions.total = extensionNames.length; + statsAccumulator.xExtensions.details = Object.fromEntries( + extensionNames.map((name) => [name, extensions[name]]) + ); +} export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => { + const extensions: SpecVendorExtensionsAccumulator = {}; + return { + SpecExtension: { + enter(node: unknown, ctx: UserContext) { + collectSpecExtension(extensions, ctx.key.toString(), node); + }, + }, ExternalDocs: { leave() { statsAccumulator.externalDocs.total++; @@ -32,6 +62,11 @@ export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => { }, }, WebhooksMap: { + enter(node: unknown, ctx: UserContext) { + if (ctx.key === 'x-webhooks') { + collectSpecExtension(extensions, 'x-webhooks', node); + } + }, Operation: { leave(operation: Oas3Operation) { statsAccumulator.webhooks.total++; @@ -43,6 +78,13 @@ export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => { }, }, }, + Operation: { + enter(operation: Oas3Operation, ctx: UserContext) { + if (ctx.key === 'x-query') { + collectSpecExtension(extensions, 'x-query', operation); + } + }, + }, Paths: { PathItem: { leave() { @@ -74,17 +116,21 @@ export const StatsOAS = (statsAccumulator: OASStatsAccumulator) => { }, Root: { leave() { - statsAccumulator.parameters.total = statsAccumulator.parameters.items!.size; - statsAccumulator.refs.total = statsAccumulator.refs.items!.size; - statsAccumulator.links.total = statsAccumulator.links.items!.size; - statsAccumulator.tags.total = statsAccumulator.tags.items!.size; + finalizeStats(statsAccumulator, extensions); }, }, }; }; export const StatsAsync2 = (statsAccumulator: AsyncAPIStatsAccumulator) => { + const extensions: SpecVendorExtensionsAccumulator = {}; + return { + SpecExtension: { + enter(node: unknown, ctx: UserContext) { + collectSpecExtension(extensions, ctx.key.toString(), node); + }, + }, ExternalDocs: { leave() { statsAccumulator.externalDocs.total++; @@ -116,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()); }, }, }, @@ -133,16 +177,21 @@ export const StatsAsync2 = (statsAccumulator: AsyncAPIStatsAccumulator) => { }, Root: { leave() { - statsAccumulator.parameters.total = statsAccumulator.parameters.items!.size; - statsAccumulator.refs.total = statsAccumulator.refs.items!.size; - statsAccumulator.tags.total = statsAccumulator.tags.items!.size; + finalizeStats(statsAccumulator, extensions); }, }, }; }; export const StatsAsync3 = (statsAccumulator: AsyncAPIStatsAccumulator) => { + const extensions: SpecVendorExtensionsAccumulator = {}; + return { + SpecExtension: { + enter(node: unknown, ctx: UserContext) { + collectSpecExtension(extensions, ctx.key.toString(), node); + }, + }, ExternalDocs: { leave() { statsAccumulator.externalDocs.total++; @@ -164,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()); }, }, }, @@ -193,9 +240,7 @@ export const StatsAsync3 = (statsAccumulator: AsyncAPIStatsAccumulator) => { }, Root: { leave() { - statsAccumulator.parameters.total = statsAccumulator.parameters.items!.size; - statsAccumulator.refs.total = statsAccumulator.refs.items!.size; - statsAccumulator.tags.total = statsAccumulator.tags.items!.size; + finalizeStats(statsAccumulator, extensions); }, }, }; diff --git a/packages/core/src/typings/common.ts b/packages/core/src/typings/common.ts index 294747bfe7..de32a02997 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; + details?: SpecVendorExtensionsAccumulator; } 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; diff --git a/packages/core/src/utils/__tests__/spec-extensions.test.ts b/packages/core/src/utils/__tests__/spec-extensions.test.ts new file mode 100644 index 0000000000..98e52f0556 --- /dev/null +++ b/packages/core/src/utils/__tests__/spec-extensions.test.ts @@ -0,0 +1,83 @@ +import type { SpecVendorExtensionsAccumulator } from '../../typings/common.js'; +import { collectSpecExtension } 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('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 new file mode 100644 index 0000000000..6406bb2293 --- /dev/null +++ b/packages/core/src/utils/spec-extensions.ts @@ -0,0 +1,95 @@ +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 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) ) ); } 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: diff --git a/tests/e2e/stats/stats-async2-json/snapshot.txt b/tests/e2e/stats/stats-async2-json/snapshot.txt index 0a82e4236d..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", @@ -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..9b9dbb434e 100644 --- a/tests/e2e/stats/stats-async2-stylish/snapshot.txt +++ b/tests/e2e/stats/stats-async2-stylish/snapshot.txt @@ -1,10 +1,11 @@ 🚗 References: 2 📦 External Documents: 1 📈 Schemas: 1 -👉 Parameters: 0 +👉 Parameters: 1 📡 Channels: 1 👷 Operations: 1 🔖 Tags: 2 +🧩 Vendor Extensions: 0 Document: async.yaml stats: 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 2a254b6490..6f45b71bfa 100644 --- a/tests/e2e/stats/stats-async3-stylish/snapshot.txt +++ b/tests/e2e/stats/stats-async3-stylish/snapshot.txt @@ -1,10 +1,11 @@ 🚗 References: 4 📦 External Documents: 0 📈 Schemas: 1 -👉 Parameters: 0 +👉 Parameters: 2 📡 Channels: 1 👷 Operations: 1 🔖 Tags: 2 +🧩 Vendor Extensions: 0 Document: asyncapi3.yaml stats: 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/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 new file mode 100644 index 0000000000..f09a8158b0 --- /dev/null +++ b/tests/e2e/stats/stats-extensions/openapi.yaml @@ -0,0 +1,81 @@ +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 + x-query: + operationId: webhookQuery + responses: + '200': + description: ok +paths: + x-paths-ext: true + /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': + $ref: '#/components/responses/SharedResponse' + x-query: + operationId: queryA + tags: + - query-tag + 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': + $ref: '#/components/responses/SharedResponse' + x-sibling-ext: true +components: + parameters: + Shared: + name: p + in: query + 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-asyncapi-stylish.txt b/tests/e2e/stats/stats-extensions/snapshot-asyncapi-stylish.txt new file mode 100644 index 0000000000..98f16094a4 --- /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-extensions/snapshot-asyncapi3-stylish.txt b/tests/e2e/stats/stats-extensions/snapshot-asyncapi3-stylish.txt new file mode 100644 index 0000000000..3df9cf9d42 --- /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 new file mode 100644 index 0000000000..8f7bd0f241 --- /dev/null +++ b/tests/e2e/stats/stats-extensions/snapshot-json.txt @@ -0,0 +1,59 @@ +{ + "refs": { + "metric": "🚗 References", + "total": 2 + }, + "externalDocs": { + "metric": "📦 External Documents", + "total": 0 + }, + "schemas": { + "metric": "📈 Schemas", + "total": 2 + }, + "parameters": { + "metric": "👉 Parameters", + "total": 1 + }, + "links": { + "metric": "🔗 Links", + "total": 0 + }, + "pathItems": { + "metric": "🔀 Path Items", + "total": 2 + }, + "webhooks": { + "metric": "🎣 Webhooks", + "total": 2 + }, + "operations": { + "metric": "👷 Operations", + "total": 3 + }, + "tags": { + "metric": "🔖 Tags", + "total": 2 + }, + "xExtensions": { + "metric": "🧩 Vendor Extensions", + "total": 10, + "counts": { + "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: + + +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..2f8912b6d2 --- /dev/null +++ b/tests/e2e/stats/stats-extensions/snapshot-markdown.txt @@ -0,0 +1,32 @@ +| Feature | Count | +| --- | --- | +| 🚗 References | 2 | +| 📦 External Documents | 0 | +| 📈 Schemas | 2 | +| 👉 Parameters | 1 | +| 🔗 Links | 0 | +| 🔀 Path Items | 2 | +| 🎣 Webhooks | 2 | +| 👷 Operations | 3 | +| 🔖 Tags | 2 | +| 🧩 Vendor Extensions | 10 | + +#### 🧩 Vendor Extensions +| Extension | Count | +| --- | --- | +| 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: + + +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..dabad5937a --- /dev/null +++ b/tests/e2e/stats/stats-extensions/snapshot-stylish.txt @@ -0,0 +1,26 @@ +🚗 References: 2 +📦 External Documents: 0 +📈 Schemas: 2 +👉 Parameters: 1 +🔗 Links: 0 +🔀 Path Items: 2 +🎣 Webhooks: 2 +👷 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 + +Document: openapi.yaml stats: + + +openapi.yaml: stats processed in ms + 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: diff --git a/tests/e2e/stats/stats.test.ts b/tests/e2e/stats/stats.test.ts index 2ae7473f06..5ac5c2e0b8 100644 --- a/tests/e2e/stats/stats.test.ts +++ b/tests/e2e/stats/stats.test.ts @@ -50,4 +50,45 @@ 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 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 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']); + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(testPath, 'snapshot-markdown.txt') + ); + }); });