feat: add vendor extensions metric with per-extension counts - #3021
feat: add vendor extensions metric with per-extension counts#3021n0rahh wants to merge 29 commits into
Conversation
🦋 Changeset detectedLatest commit: 33e26b1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Performance Benchmark (Lower is Faster)
|
|
📦 A new experimental 🧪 version v0.0.0-snapshot.1785934483 of Redocly CLI has been published for testing. Install with NPM: npm install @redocly/cli@0.0.0-snapshot.1785934483 |
… to $ref and ignore map keys starting with x-
|
📦 A new experimental 🧪 version v0.0.0-snapshot.1786099785 of Redocly CLI has been published for testing. Install with NPM: npm install @redocly/cli@0.0.0-snapshot.1786099785 |
|
I found things I didn't know existed in there. Thank you. |
|
|
||
| currentLocation = resolvedLocation; | ||
| const isNodeSeen = seenNodesPerType[type.name]?.has?.(resolvedNode); | ||
| const seenKey = type === SpecExtension ? location.absolutePointer : resolvedNode; |
There was a problem hiding this comment.
SpecExtension nodes are often equal scalars (true, "internal"), and deduping them by node value made the walker visit only the first occurrence document-wide — deduping by location visits every occurrence exactly once (while a $ref-shared extension still counts once), which the stats need to count extensions correctly.
There was a problem hiding this comment.
There are plenty of other scalar nodes. It looks like you've found a but that has to be fixed instead of masking.
| export function ensureSpecExtensionDispatch(types: Record<string, NormalizedNodeType>) { | ||
| for (const type of Object.values(types)) { | ||
| if (type === SpecExtension) continue; | ||
| type.extensionsPrefix ??= EXTENSION_PREFIX; |
There was a problem hiding this comment.
Not sure this is still needed after using the SpecExtension visitor.
There was a problem hiding this comment.
It is needed: the walker only dispatches an x- key to SpecExtension when the type declares extensionsPrefix, and most AsyncAPI types (everything except SecurityScheme) plus Paths in OAS never declare it. Removing this line drops the AsyncAPI fixture's extension count — only the additionalProperties: {} path survives.
Adding extensionsPrefix to the AsyncAPI type definitions themselves would make extension values walked (and their $refs bundled/linted) for every consumer, which is a behavior change beyond stats — could be a follow-up if we want it.
There was a problem hiding this comment.
So we can modify types to declare the extensions where needed. The purpose of having right visitors tree is exactly to avoid manipulations like this.
| } | ||
| } | ||
| const extensionNames = Object.keys(extensions).sort(); | ||
| statsAccumulator.xExtensions.total = extensionNames.length; |
There was a problem hiding this comment.
Why not use the same approach with Set like in other rows?
There was a problem hiding this comment.
Moreover, maybe we can simply calculate the items' size in place instead of assigning the total?
There was a problem hiding this comment.
On Set question already answered above.
Four consumers (three printers and the portal collector) read .total uniformly for every row, so deriving the size at read time would push an items ? items.size : total check into each of them. This one-time assignment at Root.leave also predates the PR — the loop just replaces the four per-row copies that main already had.
| 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) => |
There was a problem hiding this comment.
I don't think it's a good idea to modify types. What are you're trying to achieve?
There was a problem hiding this comment.
This modifies only the stats command's own copy of the types — lint keeps the original ones where the declarations matter for validation. Without it, typed extensions (x-codeSamples, x-logo) and most AsyncAPI nodes never reach the SpecExtension visitor, since the walker dispatches them to their declared types instead. The only alternative is going back to key-scanning in the any hook — the approach you wanted to replace with SpecExtension
There was a problem hiding this comment.
Then we have wrong types or wrong walker logic. Making different types for different commands makes no sense to me.
There was a problem hiding this comment.
Agree, most of this can be fixed at the source instead of remapping types per command
- Declare extensionsPrefix: 'x-' in the type definitions where the specs allow extensions
- In the walker, check extensionsPrefix before falling back to additionalProperties
@tatomyr wdyt?
There was a problem hiding this comment.
Agree. I believe it makes sense to do that in this PR as it directly touches the spec extensions functionality and it'd be easier to spot uncovered places.
|
|
||
| currentLocation = resolvedLocation; | ||
| const isNodeSeen = seenNodesPerType[type.name]?.has?.(resolvedNode); | ||
| const seenKey = type === SpecExtension ? location.absolutePointer : resolvedNode; |
There was a problem hiding this comment.
@n0rahh
why scope the fix to SpecExtension only? can other primitive nodes hit the same problem?
If they can, using resolvedLocation.absolutePointer as a key instead of checking type === SpecExtension would cover them all in one place
There was a problem hiding this comment.
Only SpecExtension holds arbitrary user data, so equal scalar values like true are its normal case — other node types hold objects, which never collide as keys. Using the location as the key for all types isn't safe either: a YAML anchor puts the same object in many places, and every rule would then visit and report it once per place instead of once
…ions and updating counts
| } | ||
| } | ||
|
|
||
| export function collectSpecExtension( |
There was a problem hiding this comment.
Nothing in this repo reads props, the printers only use count, and CLI telemetry never sends it
So could the sampling (props, describe, these regexes) move to the portal collector instead?
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 33e26b1. Configure here.
|
📦 A new experimental 🧪 version v0.0.0-snapshot.1786626004 of Redocly CLI has been published for testing. Install with NPM: npm install @redocly/cli@0.0.0-snapshot.1786626004 |
What/Why/How?
Adds a Vendor Extensions metric to the
statscommand. It reports how many distinctx-extensions a document uses and how many times each one occurs, shown in thestylish,json, andmarkdownoutput. Works across OpenAPI and AsyncAPI.The stats visitors collect extensions through a single
SpecExtensionentrypoint. Two walker fixes make that possible:SpecExtensionnodes now dedupe by location, so every occurrence is visited — previously occurrences with equal scalar values (e.g.x-internal: trueon many operations) were visited only once. This also means visitors and configurable rules targetingSpecExtensionnow fire per occurrence.x-properties with a declared type (e.g.x-codeSamples) were walked twice, which inflated other metrics.For the stats walk only,
ensureSpecExtensionDispatchadjusts the command's normalized types so everyx-key dispatches asSpecExtension— including natively-typed extensions and AsyncAPI types that don't declareextensionsPrefix. The structural extensionsx-webhooksandx-querykeep their declared types so the webhooks/operations/tags metrics still traverse their subtrees; the visitors count those two explicitly. Lint and bundle behavior is unchanged.The collector also gathers per-extension prop names and value samples for the portal's stats collector (telemetry) via the accumulator — the CLI prints only totals and counts. Samples are bounded (20 props / 20 values per extension), long strings become a
<string:N>marker, and credential-like keys and values are masked.Fixed the
statscommand always reportingParameters: 0for AsyncAPI 2.x and 3.x descriptions. Channel parameters are keyed by name rather than carrying anameproperty, so none of them were counted.Reference
Testing
Covered with unit and e2e tests.
Published snapshot and tested
clicommands in terminal.Screenshots (optional)
Check yourself
Security
Note
Medium Risk
Walker and resolve dispatch changes affect all lint/stats walks and
SpecExtensionrule behavior (more visits, fewer duplicates); scope is large but covered by new unit and e2e tests.Overview
Adds a Vendor Extensions (
xExtensions) metric toredocly stats: total distinctx-names plus per-extension occurrence counts, surfaced in stylish, JSON, and Markdown output (including optionalcountson each metric row in JSON).The stats visitors count extensions via
SpecExtensionenter hooks; AsyncAPI parameter totals now use the channel parameter map key instead ofparameter.name, fixingParameters: 0on AsyncAPI 2.x/3.x.Core walker/resolver changes route
x-properties toSpecExtension(including whenadditionalPropertieswould otherwise match), attachSpecExtensionvisitors for declared extensions while keeping typed walks where defined, dedupe seen nodes by location for scalars so repeated identical extension values are counted separately, and avoid double-visiting declared extensions likex-codeSamples. AsyncAPI/OAS type trees gain broaderextensionsPrefix: 'x-'coverage; Swagger 2 scopes use a dedicatedScopesmap sox-keys inside scopes dispatch correctly.Reviewed by Cursor Bugbot for commit 33e26b1. Bugbot is set up for automated code reviews on this repo. Configure here.