Skip to content

feat: add score command#2648

Open
adamaltman wants to merge 22 commits intomainfrom
aa/api-score
Open

feat: add score command#2648
adamaltman wants to merge 22 commits intomainfrom
aa/api-score

Conversation

@adamaltman
Copy link
Copy Markdown
Member

@adamaltman adamaltman commented Mar 12, 2026

What/Why/How?

What: Adds a new score command to Redocly CLI that analyzes OpenAPI 3.x descriptions and produces two composite scores: Integration Simplicity (0-100, how easy is this API to integrate) and Agent Readiness (0-100, how usable is this API by AI agents/LLM tooling).

Why: API producers currently lack a quick, deterministic way to assess how developer-friendly or AI-agent-friendly their API descriptions are. The existing stats command counts structural elements but doesn't evaluate quality signals like documentation coverage, example presence, schema complexity, or error response structure. This command fills that gap with actionable, explainable scores.

How: The implementation follows the same pattern as the stats command (bundle + analyze), with a clean separation between metric collection and score calculation:

  • Metric collection (collectors/): Walks the bundled document, resolving internal $refs, to gather per-operation raw metrics (parameter counts, schema depth, polymorphism, description/constraint/example coverage, structured error responses, workflow dependency depth via shared schema refs).
  • Scoring (scoring.ts): Pure functions that normalize raw metrics into subscores and compute weighted composite scores. Thresholds and weights are configurable constants. anyOf is penalized more heavily than oneOf/allOf; discriminator presence improves the agent readiness polymorphism clarity subscore.
  • Hotspots (hotspots.ts): Identifies the operations with the most issues, sorted by number of reasons, with human-readable explanations.
  • Output: --format=stylish (default, with color bar charts) and --format=json (machine-readable for CI/dashboards).

Reference

Related to API governance and developer experience tooling. No existing issue -- this is a new feature.

Testing

  • 43 unit tests across 3 test files covering schema depth calculation, $ref resolution, polymorphism counting (oneOf/anyOf/allOf), constraint detection (including const), example coverage scoring, anyOf penalty multiplier, discriminator impact on agent readiness, deterministic output, and score range validation.
  • Manually tested against three real OpenAPI descriptions (Redocly Cafe: 12 operations, Reunite Main: 299 operations, Rebilly: 606 operations) to verify scores are reasonable and hotspot reasoning is actionable.
  • TypeScript compiles cleanly (tsc --noEmit), all existing tests continue to pass.

Screenshots (optional)

Stylish output for Redocly Cafe:

  Scores

  Integration Simplicity:  85.3/100
  Agent Readiness:         94.4/100

  Integration Simplicity Subscores

  Parameter Simplicity     [█████████████████░░░] 83%
  Schema Simplicity        [████████████░░░░░░░░] 62%
  Documentation Quality    [███████████████████░] 97%
  Constraint Clarity       [███████████████████░] 96%
  Example Coverage         [██████████████████░░] 92%
  Error Clarity            [████████████████████] 100%
  Workflow Clarity         [█████████████████░░░] 83%

  Top 4 Hotspot Operations

  GET /orders (listOrders)
    Integration Simplicity: 69.1  Agent Readiness: 93.9
    - High parameter count (6)
    - Deep schema nesting (depth 5)

  PATCH /orders/{orderId} (updateOrder)
    Integration Simplicity: 77.6  Agent Readiness: 85.3
    - Missing request body examples

Check yourself

  • Code changed? - Tested with Redoc/Realm/Reunite (internal)
  • All new/updated code is covered by tests
  • New package installed? - Tested in different environments (browser/node)
  • Documentation update considered

Security

  • The security impact of the change has been considered
  • Code follows company security practices and guidelines

Note

Medium Risk
Adds a new CLI command with non-trivial OpenAPI traversal/scoring logic and a small core export change (isMappingRef, Oas3MediaType), which could affect downstream consumers and output stability, though it’s mostly additive.

Overview
Adds a new experimental redocly score CLI command that bundles an OpenAPI 3.x description, collects per-operation complexity/documentation/example/error metrics, computes a composite Agent Readiness score (plus normalized subscores), and reports top “hotspot” operations with reasons. Output supports --format=stylish (default) and --format=json, plus optional --operation-details and --debug-operation-id schema breakdowns.

Implements new metric collection/walking, dependency-depth inference via shared $refs, scoring/weighting constants, hotspot selection, and dedicated formatters under packages/cli/src/commands/score. Updates CLI wiring, docs/sidebars, adds unit + e2e snapshot coverage for the new command, exports isMappingRef/Oas3MediaType from @redocly/openapi-core, and slightly lowers the global branch coverage threshold.

Reviewed by Cursor Bugbot for commit a7a0f2a. Bugbot is set up for automated code reviews on this repo. Configure here.

@adamaltman adamaltman requested review from a team as code owners March 12, 2026 01:21
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 12, 2026

🦋 Changeset detected

Latest commit: a7a0f2a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@redocly/cli Minor
@redocly/openapi-core Minor
@redocly/respect-core Minor

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

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 12, 2026

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 79.74% (🎯 79%) 7107 / 8912
🔵 Statements 79.09% (🎯 78%) 7366 / 9313
🔵 Functions 83.21% (🎯 82%) 1413 / 1698
🔵 Branches 71.09% (🎯 70%) 4772 / 6712
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/cli/src/commands/score/collect-metrics.ts 56.52% 44% 72.72% 60.8% 28, 83-85, 88-90, 93-95, 98, 111-114, 179-196, 137, 212, 237-238, 246-300
packages/cli/src/commands/score/constants.ts 100% 100% 100% 100%
packages/cli/src/commands/score/hotspots.ts 100% 97.67% 100% 100%
packages/cli/src/commands/score/index.ts 96.42% 72.72% 100% 96.42% 125
packages/cli/src/commands/score/scoring.ts 99.01% 92.15% 100% 100% 282
packages/cli/src/commands/score/collectors/dependency-graph.ts 100% 90% 100% 100%
packages/cli/src/commands/score/collectors/document-metrics.ts 92.02% 74.35% 100% 95.62% 123, 129-131, 151-157, 322, 336, 360, 392, 416-426, 457
packages/cli/src/commands/score/formatters/json.ts 100% 100% 100% 100%
packages/cli/src/commands/score/formatters/stylish.ts 58.65% 29.82% 70% 60.24% 10, 78, 79, 107, 146-268
Generated in workflow #9264 for commit 798d612 by the Vitest Coverage Report Action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 12, 2026

CLI Version Mean Time ± Std Dev (s) Relative Performance (Lower is Faster)
cli-latest 3.540s ± 0.028s ▓ 1.00x
cli-next 3.529s ± 0.019s ▓ 1.00x (Fastest)

- Add "AI" before "agent readiness" in changeset and docs for clarity
- Replace <pre> block with fenced code block in score.md
- Add security scheme coverage to metrics documentation
- Remove resolveIfRef helper, replace with resolveNode that falls back
  to the original node when resolution fails
- Refactor to use walkDocument visitor approach (matching stats command
  pattern) instead of manually iterating the document tree
- Use resolveDocument + normalizeVisitors + walkDocument from
  openapi-core for proper $ref resolution and spec-format extensibility
- Update index.test.ts to mock the new walk infrastructure

Made-with: Cursor
Co-authored-by: Jacek Łękawa <164185257+JLekawa@users.noreply.github.com>
Copy link
Copy Markdown
Collaborator

@tatomyr tatomyr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a couple of comments. I haven'd fully reviewed the scoring and collectors though as it takes time.

Copy link
Copy Markdown
Collaborator

@tatomyr tatomyr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found some dead code. Please check if it's needed and remove if not. Also, I'm not sure what tests to review as many appear to only test that dead code, so let's handle that first.

- Inline collect-metrics.ts test helper into document-metrics.test.ts
- Use parseYaml as yaml directly instead of wrapper function
- Remove default parameter from getStylishOutput in formatter tests
- Use getMajorSpecVersion + exitWithError for spec version check
- Add explicit case 'stylish' before default in format switch
- Remove unsupported 'markdown' from score command format choices
- Add comment explaining depth=-1 initialization
- Clarify anyOf penalty and dependency terminology in docs
- Update non-oas3 rejection test for exitWithError (throws)

Made-with: Cursor
- Add type cast for parseYaml (returns unknown) in document-metrics tests
- Inline collectDocumentMetrics helper into example-coverage tests
- Remove collect-metrics.js import from scoring tests, use direct metrics
- Add missing debugLogs property to accumulator mock in index tests

Made-with: Cursor
Extract the metric-collection pipeline from handleScore into a
standalone collect-metrics.ts module with two exports:
- collectMetrics(): low-level function used by handleScore
- collectDocumentMetrics(): high-level convenience used by tests

This eliminates ~300 lines of duplicated walker setup across three
test files (document-metrics, example-coverage, index) and ensures
tests exercise the same code path as the production command.

Add $ref-keyed memoization to walkSchema so repeated references to
the same component schema return cached stats instead of re-walking.
Stripe API: 37.6s → 11.3s (3.3× faster).

Made-with: Cursor
Add median alongside averages for parameters, schema depth,
polymorphism, and properties in the stylish output. Rename
the misleading "Avg max schema depth" to "Schema depth".

Made-with: Cursor
Rename workflowClarity to dependencyClarity, workflowDepths to
dependencyDepths, and workflow-graph to dependency-graph to align
code naming with the "Dependency Clarity" display label. Also adds
discoverability subscore, recursive composition keyword stripping
for accurate property counting, and updates e2e snapshots.

Made-with: Cursor
- Use isPlainObject from openapi-core for schema cycle detection
  instead of raw typeof checks that match arrays
- Remove formatter unit tests in favor of e2e snapshot coverage
- Clarify makeScores() purpose in hotspot tests

Made-with: Cursor
@adamaltman adamaltman requested a review from tatomyr March 30, 2026 16:33
- Add experimental admonition to score command docs
- Remove --config from usage examples
- Remove redundant mockClear and process.exitCode in tests
- Simplify JSON formatter by using spread instead of manual field mapping
- Use isRef from openapi-core for $ref detection in collect-metrics
- Use isPlainObject and isNotEmptyObject from openapi-core in document-metrics
- Replace != null with Array.isArray for discriminatorRefs check
- Remove unnecessary non-null assertions on discriminatorRefs
- Lower branch coverage threshold to 70% per reviewer guidance

Made-with: Cursor
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Discriminator flag lost with oneOf/anyOf polymorphism
    • polyStats now sets hasDiscriminator when the parent schema has a discriminator (propertyName or non-empty mapping), merged with branch stats via OR.
  • ✅ Fixed: Structured error count double-incremented per media type
    • Added errorResponseStructuredCounted so structuredErrorResponseCount increases at most once per error response (description-only in Response.enter, or the first MediaType.enter that counts).
  • ✅ Fixed: Independent max of correlated metrics across media types
    • totalSchemaProperties, schemaPropertiesWithDescription, and constraintCount are now summed across media types instead of maxed independently.
  • ✅ Fixed: Document parsed object mutated during metric collection
    • collectMetrics now uses try/finally so restoreCompositionKeywords always runs after stripCompositionKeywords, even when inner work throws.

Create PR

Or push these changes by commenting:

@cursor push b6568647da
Preview (b6568647da)
diff --git a/packages/cli/src/commands/score/collect-metrics.ts b/packages/cli/src/commands/score/collect-metrics.ts
--- a/packages/cli/src/commands/score/collect-metrics.ts
+++ b/packages/cli/src/commands/score/collect-metrics.ts
@@ -122,6 +122,30 @@
 }: CollectMetricsOptions): CollectMetricsResult {
   const removedComposition = stripCompositionKeywords(document.parsed);
 
+  try {
+    return collectMetricsInner({
+      document,
+      types,
+      resolvedRefMap,
+      ctx,
+      debugOperationId,
+      removedComposition,
+    });
+  } finally {
+    restoreCompositionKeywords(removedComposition);
+  }
+}
+
+function collectMetricsInner({
+  document,
+  types,
+  resolvedRefMap,
+  ctx,
+  debugOperationId,
+  removedComposition,
+}: CollectMetricsOptions & {
+  removedComposition: Map<object, StrippedComposition>;
+}): CollectMetricsResult {
   const schemaWalkState = createSchemaWalkState();
   const schemaVisitor = createSchemaMetricVisitor(schemaWalkState);
   const normalizedSchemaVisitors = normalizeVisitors(
@@ -236,6 +260,10 @@
         : null;
     const hasDiscriminatorBranches =
       Array.isArray(discriminatorRefs) && discriminatorRefs.length > 0;
+    const hasParentDiscriminator = !!(
+      disc?.propertyName ||
+      (isPlainObject(disc?.mapping) && Object.keys(disc.mapping).length > 0)
+    );
 
     let result: SchemaStats;
 
@@ -258,6 +286,7 @@
           ...maxBranch,
           polymorphismCount: maxBranch.polymorphismCount + polyBranches.length,
           anyOfCount: maxBranch.anyOfCount + (polyKeyword === 'anyOf' ? polyBranches.length : 0),
+          hasDiscriminator: maxBranch.hasDiscriminator || hasParentDiscriminator,
         };
       }
 
@@ -323,8 +352,6 @@
     ctx,
   });
 
-  restoreCompositionKeywords(removedComposition);
-
   return {
     metrics: getDocumentMetrics(accumulator),
     debugLogs: accumulator.debugLogs,

diff --git a/packages/cli/src/commands/score/collectors/document-metrics.ts b/packages/cli/src/commands/score/collectors/document-metrics.ts
--- a/packages/cli/src/commands/score/collectors/document-metrics.ts
+++ b/packages/cli/src/commands/score/collectors/document-metrics.ts
@@ -212,6 +212,8 @@
   inRequestBody: boolean;
   inResponse: boolean;
   currentResponseCode: string;
+  /** True once structured error counting ran for the current error response (Response or first MediaType). */
+  errorResponseStructuredCounted: boolean;
 
   refsUsed: Set<string>;
 }
@@ -278,6 +280,7 @@
     inRequestBody: false,
     inResponse: false,
     currentResponseCode: '',
+    errorResponseStructuredCounted: false,
 
     refsUsed: new Set(),
   };
@@ -337,8 +340,10 @@
 
         if (isErrorCode(code)) {
           current.totalErrorResponses++;
+          current.errorResponseStructuredCounted = false;
           if (!response.content && response.description) {
             current.structuredErrorResponseCount++;
+            current.errorResponseStructuredCounted = true;
           }
         }
       },
@@ -359,8 +364,13 @@
           if (current.inResponse) current.responseExamplePresent = true;
         }
 
-        if (current.inResponse && isErrorCode(current.currentResponseCode)) {
+        if (
+          current.inResponse &&
+          isErrorCode(current.currentResponseCode) &&
+          !current.errorResponseStructuredCounted
+        ) {
           current.structuredErrorResponseCount++;
+          current.errorResponseStructuredCounted = true;
         }
 
         if (mediaType.schema) {
@@ -372,15 +382,9 @@
           const stats = accumulator.walkSchema(mediaType.schema, isDebugTarget);
 
           current.propertyCount = Math.max(current.propertyCount, stats.propertyCount);
-          current.totalSchemaProperties = Math.max(
-            current.totalSchemaProperties,
-            stats.totalSchemaProperties
-          );
-          current.schemaPropertiesWithDescription = Math.max(
-            current.schemaPropertiesWithDescription,
-            stats.schemaPropertiesWithDescription
-          );
-          current.constraintCount = Math.max(current.constraintCount, stats.constraintCount);
+          current.totalSchemaProperties += stats.totalSchemaProperties;
+          current.schemaPropertiesWithDescription += stats.schemaPropertiesWithDescription;
+          current.constraintCount += stats.constraintCount;
           current.polymorphismCount = Math.max(current.polymorphismCount, stats.polymorphismCount);
           current.anyOfCount = Math.max(current.anyOfCount, stats.anyOfCount);
           if (stats.hasDiscriminator) current.hasDiscriminator = true;

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

- Fix duplicate import from @redocly/openapi-core (CI lint error)
- Propagate hasDiscriminator flag for oneOf/anyOf + discriminator schemas
- Prevent structured error response double-counting per media type
- Keep totalSchemaProperties and schemaPropertiesWithDescription paired
  from the same schema to avoid misleading documentation quality ratios
- Wrap composition stripping in try/finally to guarantee restoration
- Export isMappingRef from openapi-core and use in collect-metrics
- Rename bfsMaxDepth to computeLongestBfsPath for clarity

Made-with: Cursor
- Add allOf member count to polymorphismCount for parity with
  oneOf/anyOf branch counting
- Handle RFC 6901 JSON Pointer escaping (~0 → ~ and ~1 → /) in
  resolveJsonPointer
- Remove unused scores parameter from getHotspotReasons

Made-with: Cursor
Copy link
Copy Markdown
Collaborator

@tatomyr tatomyr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These metrics look like a duplication:

Image

Is it intended?

@adamaltman
Copy link
Copy Markdown
Member Author

Yes, I think there is too much overlap between the two scores so I'm going to consolidate it into one. It will make docs easier, result understanding easier, etc... Addressing other comments too.

… single score

Merge the two overlapping composite scores into a single "Agent Readiness"
score with all 9 subscores. This eliminates duplication in weights, scoring
functions, and output. Also addresses code review feedback: use
unescapePointerFragment, simplify resetSchemaWalkState via Object.assign,
consolidate SchemaStats into types.ts, remove collect() test wrapper, simplify
hasExample return, and use spread for walkSchemaRaw return.

Made-with: Cursor
- Remove global stripCompositionKeywords that silently lost nested
  oneOf/anyOf within properties/items — the parentOnly shallow copy
  already prevents double-counting at the top level while preserving
  nested composition for walkSchemaRaw to count naturally
- Remove unused maxPropertiesGood threshold (dead code)
- Extract duplicate median function to shared utils.ts

Made-with: Cursor
@adamaltman
Copy link
Copy Markdown
Member Author

Addressed all remaining review comments. The two scores have been consolidated into a single Agent Readiness score with all 9 subscores. Also removed the global stripCompositionKeywords mechanism (Bugbot's nested-composition bug), unused maxPropertiesGood threshold, and deduplicated the median function into a shared utility.

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Constraint clarity mixes metrics from different media types
    • Media-type stats are now chosen together via a combined bundle score so constraintCount, totalSchemaProperties, and schemaPropertiesWithDescription always refer to the same walk.
  • ✅ Fixed: Redundant propertyCount and totalSchemaProperties always equal
    • Removed propertyCount from SchemaStats and OperationMetrics and use totalSchemaProperties everywhere, including polymorphic branch selection in collect-metrics.

Create PR

Or push these changes by commenting:

@cursor push dfca434d5c
Preview (dfca434d5c)
diff --git a/packages/cli/src/commands/score/__tests__/dependency-graph.test.ts b/packages/cli/src/commands/score/__tests__/dependency-graph.test.ts
--- a/packages/cli/src/commands/score/__tests__/dependency-graph.test.ts
+++ b/packages/cli/src/commands/score/__tests__/dependency-graph.test.ts
@@ -15,7 +15,6 @@
     polymorphismCount: 0,
     anyOfCount: 0,
     hasDiscriminator: false,
-    propertyCount: 0,
     operationDescriptionPresent: false,
     schemaPropertiesWithDescription: 0,
     totalSchemaProperties: 0,

diff --git a/packages/cli/src/commands/score/__tests__/document-metrics.test.ts b/packages/cli/src/commands/score/__tests__/document-metrics.test.ts
--- a/packages/cli/src/commands/score/__tests__/document-metrics.test.ts
+++ b/packages/cli/src/commands/score/__tests__/document-metrics.test.ts
@@ -131,7 +131,7 @@
     const op = metrics.operations.get('createItem')!;
     expect(op.requestBodyPresent).toBe(true);
     expect(op.requestExamplePresent).toBe(true);
-    expect(op.propertyCount).toBe(2);
+    expect(op.totalSchemaProperties).toBe(2);
     expect(op.constraintCount).toBe(2);
     expect(op.schemaPropertiesWithDescription).toBe(1);
   });

diff --git a/packages/cli/src/commands/score/__tests__/hotspots.test.ts b/packages/cli/src/commands/score/__tests__/hotspots.test.ts
--- a/packages/cli/src/commands/score/__tests__/hotspots.test.ts
+++ b/packages/cli/src/commands/score/__tests__/hotspots.test.ts
@@ -16,7 +16,6 @@
     polymorphismCount: 0,
     anyOfCount: 0,
     hasDiscriminator: false,
-    propertyCount: 0,
     operationDescriptionPresent: true,
     schemaPropertiesWithDescription: 0,
     totalSchemaProperties: 0,

diff --git a/packages/cli/src/commands/score/__tests__/index.test.ts b/packages/cli/src/commands/score/__tests__/index.test.ts
--- a/packages/cli/src/commands/score/__tests__/index.test.ts
+++ b/packages/cli/src/commands/score/__tests__/index.test.ts
@@ -51,7 +51,6 @@
     polymorphismCount: 0,
     anyOfCount: 0,
     hasDiscriminator: false,
-    propertyCount: 1,
     operationDescriptionPresent: true,
     schemaPropertiesWithDescription: 0,
     totalSchemaProperties: 1,

diff --git a/packages/cli/src/commands/score/__tests__/scoring.test.ts b/packages/cli/src/commands/score/__tests__/scoring.test.ts
--- a/packages/cli/src/commands/score/__tests__/scoring.test.ts
+++ b/packages/cli/src/commands/score/__tests__/scoring.test.ts
@@ -23,7 +23,6 @@
     polymorphismCount: 0,
     anyOfCount: 0,
     hasDiscriminator: false,
-    propertyCount: 0,
     operationDescriptionPresent: true,
     schemaPropertiesWithDescription: 0,
     totalSchemaProperties: 0,
@@ -132,7 +131,6 @@
               parameterCount: 1,
               requiredParameterCount: 1,
               paramsWithDescription: 0,
-              propertyCount: 1,
               totalSchemaProperties: 1,
               maxResponseSchemaDepth: 1,
             }),
@@ -146,7 +144,6 @@
               requestBodyPresent: true,
               requestExamplePresent: true,
               constraintCount: 1,
-              propertyCount: 2,
               totalSchemaProperties: 2,
               maxRequestSchemaDepth: 1,
               maxResponseSchemaDepth: 1,

diff --git a/packages/cli/src/commands/score/collect-metrics.ts b/packages/cli/src/commands/score/collect-metrics.ts
--- a/packages/cli/src/commands/score/collect-metrics.ts
+++ b/packages/cli/src/commands/score/collect-metrics.ts
@@ -87,7 +87,6 @@
     polymorphismCount: 0,
     anyOfCount: 0,
     hasDiscriminator: false,
-    propertyCount: 0,
     totalSchemaProperties: 0,
     schemaPropertiesWithDescription: 0,
     constraintCount: 0,
@@ -102,7 +101,6 @@
       polymorphismCount: a.polymorphismCount + b.polymorphismCount,
       anyOfCount: a.anyOfCount + b.anyOfCount,
       hasDiscriminator: a.hasDiscriminator || b.hasDiscriminator,
-      propertyCount: a.propertyCount + b.propertyCount,
       totalSchemaProperties: a.totalSchemaProperties + b.totalSchemaProperties,
       schemaPropertiesWithDescription:
         a.schemaPropertiesWithDescription + b.schemaPropertiesWithDescription,
@@ -172,7 +170,7 @@
         let maxBranch = walkSchema(polyBranches[0], debug);
         for (let i = 1; i < polyBranches.length; i++) {
           const branchStats = walkSchema(polyBranches[i], debug);
-          if (branchStats.propertyCount > maxBranch.propertyCount) {
+          if (branchStats.totalSchemaProperties > maxBranch.totalSchemaProperties) {
             maxBranch = branchStats;
           }
         }
@@ -191,7 +189,7 @@
         let maxBranch = walkSchema(discriminatorRefs[0], debug);
         for (let i = 1; i < discriminatorRefs.length; i++) {
           const branchStats = walkSchema(discriminatorRefs[i], debug);
-          if (branchStats.propertyCount > maxBranch.propertyCount) {
+          if (branchStats.totalSchemaProperties > maxBranch.totalSchemaProperties) {
             maxBranch = branchStats;
           }
         }

diff --git a/packages/cli/src/commands/score/collectors/document-metrics.ts b/packages/cli/src/commands/score/collectors/document-metrics.ts
--- a/packages/cli/src/commands/score/collectors/document-metrics.ts
+++ b/packages/cli/src/commands/score/collectors/document-metrics.ts
@@ -49,7 +49,6 @@
   polymorphismCount: number;
   anyOfCount: number;
   hasDiscriminator: boolean;
-  propertyCount: number;
   totalSchemaProperties: number;
   schemaPropertiesWithDescription: number;
   constraintCount: number;
@@ -68,7 +67,6 @@
     polymorphismCount: 0,
     anyOfCount: 0,
     hasDiscriminator: false,
-    propertyCount: 0,
     totalSchemaProperties: 0,
     schemaPropertiesWithDescription: 0,
     constraintCount: 0,
@@ -122,7 +120,6 @@
         if (isPlainObject(schema.properties)) {
           const props = Object.entries(schema.properties) as [string, any][];
           state.totalSchemaProperties += props.length;
-          state.propertyCount += props.length;
 
           for (const [name, prop] of props) {
             localPropertyNames.push(name);
@@ -166,7 +163,6 @@
 
   maxRequestSchemaDepth: number;
   maxResponseSchemaDepth: number;
-  propertyCount: number;
   totalSchemaProperties: number;
   schemaPropertiesWithDescription: number;
   constraintCount: number;
@@ -187,6 +183,9 @@
   currentResponseCode: string;
   errorStructuredCounted: boolean;
 
+  /** Best-scoring media type for constraint + property metrics (see `mediaTypeBundleScore`). */
+  mediaTypeBundleScore: number;
+
   refsUsed: Set<string>;
 }
 
@@ -233,7 +232,6 @@
 
     maxRequestSchemaDepth: 0,
     maxResponseSchemaDepth: 0,
-    propertyCount: 0,
     totalSchemaProperties: 0,
     schemaPropertiesWithDescription: 0,
     constraintCount: 0,
@@ -254,6 +252,8 @@
     currentResponseCode: '',
     errorStructuredCounted: false,
 
+    mediaTypeBundleScore: -1,
+
     refsUsed: new Set(),
   };
 }
@@ -273,7 +273,6 @@
     polymorphismCount: ctx.polymorphismCount,
     anyOfCount: ctx.anyOfCount,
     hasDiscriminator: ctx.hasDiscriminator,
-    propertyCount: ctx.propertyCount,
     operationDescriptionPresent: ctx.operationDescriptionPresent,
     schemaPropertiesWithDescription: ctx.schemaPropertiesWithDescription,
     totalSchemaProperties: ctx.totalSchemaProperties,
@@ -350,12 +349,13 @@
 
           const stats = accumulator.walkSchema(mediaType.schema, isDebugTarget);
 
-          current.propertyCount = Math.max(current.propertyCount, stats.propertyCount);
-          if (stats.totalSchemaProperties > current.totalSchemaProperties) {
+          const bundleScore = mediaTypeBundleScore(stats);
+          if (bundleScore > current.mediaTypeBundleScore) {
+            current.mediaTypeBundleScore = bundleScore;
             current.totalSchemaProperties = stats.totalSchemaProperties;
             current.schemaPropertiesWithDescription = stats.schemaPropertiesWithDescription;
+            current.constraintCount = stats.constraintCount;
           }
-          current.constraintCount = Math.max(current.constraintCount, stats.constraintCount);
           current.polymorphismCount = Math.max(current.polymorphismCount, stats.polymorphismCount);
           current.anyOfCount = Math.max(current.anyOfCount, stats.anyOfCount);
           if (stats.hasDiscriminator) current.hasDiscriminator = true;
@@ -388,7 +388,7 @@
             accumulator.debugLogs.push({
               context,
               entries: stats.debugEntries,
-              totalProperties: stats.propertyCount,
+              totalProperties: stats.totalSchemaProperties,
               totalPolymorphism: stats.polymorphismCount,
               totalConstraints: stats.constraintCount,
               maxDepth: stats.maxDepth,
@@ -475,3 +475,13 @@
   const num = parseInt(code, 10);
   return num >= 400 && num < 600;
 }
+
+/** Matches `propCount` in scoring.ts so operation-level rollup stays from one media type. */
+function propCountForScoring(totalSchemaProperties: number): number {
+  return totalSchemaProperties || 1;
+}
+
+function mediaTypeBundleScore(stats: SchemaStats): number {
+  const p = propCountForScoring(stats.totalSchemaProperties);
+  return stats.constraintCount / p + stats.schemaPropertiesWithDescription / p;
+}

diff --git a/packages/cli/src/commands/score/formatters/stylish.ts b/packages/cli/src/commands/score/formatters/stylish.ts
--- a/packages/cli/src/commands/score/formatters/stylish.ts
+++ b/packages/cli/src/commands/score/formatters/stylish.ts
@@ -85,7 +85,7 @@
     params.push(m.parameterCount);
     depths.push(Math.max(m.maxRequestSchemaDepth, m.maxResponseSchemaDepth));
     polys.push(m.polymorphismCount);
-    props.push(m.propertyCount);
+    props.push(m.totalSchemaProperties);
     if (m.requestExamplePresent) opsWithReqExample++;
     if (m.responseExamplePresent) opsWithResExample++;
     if (m.operationDescriptionPresent) opsWithDescription++;
@@ -140,7 +140,7 @@
   out(cyan('  ' + '─'.repeat(header.length - 2)));
 
   const entries = [...result.rawMetrics.operations.entries()].sort(
-    ([, a], [, b]) => b.propertyCount - a.propertyCount
+    ([, a], [, b]) => b.totalSchemaProperties - a.totalSchemaProperties
   );
 
   for (const [, m] of entries) {
@@ -149,7 +149,7 @@
     const line =
       '  ' +
       label.padEnd(50) +
-      String(m.propertyCount).padStart(7) +
+      String(m.totalSchemaProperties).padStart(7) +
       String(m.polymorphismCount).padStart(7) +
       String(depth).padStart(7) +
       String(m.parameterCount).padStart(8) +

diff --git a/packages/cli/src/commands/score/types.ts b/packages/cli/src/commands/score/types.ts
--- a/packages/cli/src/commands/score/types.ts
+++ b/packages/cli/src/commands/score/types.ts
@@ -15,7 +15,6 @@
   polymorphismCount: number;
   anyOfCount: number;
   hasDiscriminator: boolean;
-  propertyCount: number;
 
   operationDescriptionPresent: boolean;
   schemaPropertiesWithDescription: number;
@@ -125,7 +124,6 @@
   polymorphismCount: number;
   anyOfCount: number;
   hasDiscriminator: boolean;
-  propertyCount: number;
   totalSchemaProperties: number;
   schemaPropertiesWithDescription: number;
   constraintCount: number;

diff --git a/tests/e2e/score/score-json/snapshot.txt b/tests/e2e/score/score-json/snapshot.txt
--- a/tests/e2e/score/score-json/snapshot.txt
+++ b/tests/e2e/score/score-json/snapshot.txt
@@ -4,8 +4,8 @@
   "subscores": {
     "parameterSimplicity": 0.8625,
     "schemaSimplicity": 0.796875,
-    "documentationQuality": 0.9017857142857143,
-    "constraintClarity": 0.5833333333333333,
+    "documentationQuality": 0.8988095238095238,
+    "constraintClarity": 0.6,
     "exampleCoverage": 0.875,
     "errorClarity": 1,
     "dependencyClarity": 0.7291666666666669,
@@ -29,7 +29,6 @@
         "polymorphismCount": 0,
         "anyOfCount": 0,
         "hasDiscriminator": false,
-        "propertyCount": 3,
         "operationDescriptionPresent": true,
         "schemaPropertiesWithDescription": 2,
         "totalSchemaProperties": 3,
@@ -54,7 +53,6 @@
         "polymorphismCount": 0,
         "anyOfCount": 0,
         "hasDiscriminator": false,
-        "propertyCount": 6,
         "operationDescriptionPresent": true,
         "schemaPropertiesWithDescription": 6,
         "totalSchemaProperties": 6,
@@ -79,7 +77,6 @@
         "polymorphismCount": 0,
         "anyOfCount": 0,
         "hasDiscriminator": false,
-        "propertyCount": 6,
         "operationDescriptionPresent": true,
         "schemaPropertiesWithDescription": 6,
         "totalSchemaProperties": 6,
@@ -104,7 +101,6 @@
         "polymorphismCount": 0,
         "anyOfCount": 0,
         "hasDiscriminator": false,
-        "propertyCount": 6,
         "operationDescriptionPresent": true,
         "schemaPropertiesWithDescription": 6,
         "totalSchemaProperties": 6,
@@ -129,7 +125,6 @@
         "polymorphismCount": 0,
         "anyOfCount": 0,
         "hasDiscriminator": false,
-        "propertyCount": 2,
         "operationDescriptionPresent": true,
         "schemaPropertiesWithDescription": 0,
         "totalSchemaProperties": 2,
@@ -154,7 +149,6 @@
         "polymorphismCount": 0,
         "anyOfCount": 0,
         "hasDiscriminator": false,
-        "propertyCount": 6,
         "operationDescriptionPresent": true,
         "schemaPropertiesWithDescription": 6,
         "totalSchemaProperties": 6,
@@ -179,10 +173,9 @@
         "polymorphismCount": 0,
         "anyOfCount": 0,
         "hasDiscriminator": false,
-        "propertyCount": 6,
         "operationDescriptionPresent": true,
-        "schemaPropertiesWithDescription": 5,
-        "totalSchemaProperties": 6,
+        "schemaPropertiesWithDescription": 4,
+        "totalSchemaProperties": 5,
         "constraintCount": 4,
         "requestExamplePresent": true,
         "responseExamplePresent": true,
@@ -204,7 +197,6 @@
         "polymorphismCount": 0,
         "anyOfCount": 0,
         "hasDiscriminator": false,
-        "propertyCount": 0,
         "operationDescriptionPresent": true,
         "schemaPropertiesWithDescription": 0,
         "totalSchemaProperties": 0,
@@ -303,12 +295,12 @@
       }
     },
     "buyMuseumTickets": {
-      "agentReadiness": 88.8,
+      "agentReadiness": 90,
       "subscores": {
         "parameterSimplicity": 1,
         "schemaSimplicity": 0.875,
-        "documentationQuality": 0.8571428571428571,
-        "constraintClarity": 0.6666666666666666,
+        "documentationQuality": 0.8333333333333334,
+        "constraintClarity": 0.8,
         "exampleCoverage": 1,
         "errorClarity": 1,
         "dependencyClarity": 0.6666666666666667,

diff --git a/tests/e2e/score/score-stylish/snapshot.txt b/tests/e2e/score/score-stylish/snapshot.txt
--- a/tests/e2e/score/score-stylish/snapshot.txt
+++ b/tests/e2e/score/score-stylish/snapshot.txt
@@ -8,7 +8,7 @@
   Parameter Simplicity     [█████████████████░░░] 86%
   Schema Simplicity        [████████████████░░░░] 80%
   Documentation Quality    [██████████████████░░] 90%
-  Constraint Clarity       [████████████░░░░░░░░] 58%
+  Constraint Clarity       [████████████░░░░░░░░] 60%
   Example Coverage         [██████████████████░░] 88%
   Error Clarity            [████████████████████] 100%
   Dependency Clarity       [███████████████░░░░░] 73%
@@ -22,7 +22,7 @@
   Parameters/operation:      avg    1.4  median    1.0  min     0  max     4
   Schema depth:              avg    1.6  median    2.0  min     0  max     3
   Polymorphism/operation:    avg    0.0  median    0.0  min     0  max     0
-  Properties/operation:      avg    4.4  median    6.0  min     0  max     6
+  Properties/operation:      avg    4.3  median    5.5  min     0  max     6
   Operations with request examples: 3/8 (38%)
   Operations with response examples: 7/8 (88%)
   Operations with description: 8/8 (100%)

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

- Remove as any casts from normalizeVisitors — types infer correctly
- Remove redundant propertyCount (always equals totalSchemaProperties)
- Fix constraintCount mixing across media types — keep all three
  correlated metrics from the same schema walk
- Consolidate CurrentOperationContext with OperationMetrics field names,
  simplify buildOperationMetrics to destructuring spread
- Export Oas3MediaType from openapi-core, use in hasExample signature
- Remove any type on MediaType.enter — visitor infers type by default
- Move collectDocumentMetrics to __tests__/collect-metrics-helper.ts
  since it is test-only code

Made-with: Cursor
@adamaltman
Copy link
Copy Markdown
Member Author

Addressed all review comments in fdb8c94:

  1. Removed as any casts — both normalizeVisitors calls compile cleanly without them.
  2. Consolidated CurrentOperationContextOperationMetrics — renamed writableTopLevelFieldCount to topLevelWritableFieldCount, simplified buildOperationMetrics to a destructuring spread.
  3. Exported Oas3MediaType from @redocly/openapi-core and use it in hasExample.
  4. Removed any type from MediaType.enter — visitor infers the type by default.
  5. Moved collectDocumentMetrics to __tests__/collect-metrics-helper.ts since it's test-only.
  6. Removed duplicate propertyCount (Bugbot) — totalSchemaProperties is used everywhere now.
  7. Fixed constraintCount media type mixing (Bugbot) — constraintCount, totalSchemaProperties, and schemaPropertiesWithDescription now all come from the same schema walk.

result.polymorphismClarity /= n;

return result;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Document subscores and composite score use inconsistent aggregation

Medium Severity

aggregateSubscores computes the arithmetic mean of per-operation subscores, while computeDocumentScores computes the median of per-operation composite scores. Because median-of-weighted-sums ≠ weighted-sum-of-means, applying computeAgentReadiness to the displayed aggregate subscores yields a different number than the displayed agentReadiness. The subscores shown to the user don't actually explain the composite score, which is misleading.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fdb8c94. Configure here.

current.totalSchemaProperties = stats.totalSchemaProperties;
current.schemaPropertiesWithDescription = stats.schemaPropertiesWithDescription;
current.constraintCount = stats.constraintCount;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Max-wins schema replacement discards smaller schema quality metrics

Medium Severity

When multiple media types are processed for an operation, schemaPropertiesWithDescription and constraintCount are replaced atomically based on which schema has the highest totalSchemaProperties. If a response schema has more properties but fewer descriptions/constraints than the request body schema, the request body's quality metrics are silently discarded. This causes documentationQuality and constraintClarity subscores to be inaccurately low for operations where the request schema is better documented than the response schema.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fdb8c94. Configure here.

Copy link
Copy Markdown
Collaborator

@tatomyr tatomyr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. There are a few bugbot comments left -- please review whether they make sense.

- Preserve top-level $ref in refsUsed when composition branch is taken,
  so computeDependencyDepths correctly tracks cross-operation schema
  sharing for oneOf/anyOf/allOf/discriminator schemas
- Return constraintClarity=1 when totalSchemaProperties is 0, matching
  errorClarity's "no data" pattern — avoids unfairly penalizing DELETE
  and binary-returning endpoints that have no schema properties

Made-with: Cursor
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 4 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a7a0f2a. Configure here.

prop && '$ref' in prop && prop.$ref ? (ctx.resolve(prop)?.node ?? prop) : prop;
if (res?.description) state.schemaPropertiesWithDescription++;
if (res?.example !== undefined || res?.examples) state.hasPropertyExamples = true;
if (!res?.readOnly) state.writableTopLevelFields++;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Writable field counter ignores depth, miscounts properties

Medium Severity

The writableTopLevelFields counter in createSchemaMetricVisitor increments for every non-readOnly property at every nesting depth, not just at depth 0. Since Schema.enter is called recursively for nested schemas, a schema like { name: string, address: { street: string, city: string } } would report 3 writable "top-level" fields instead of 2. This incorrect value flows through SchemaStats.writableTopLevelFieldsOperationMetrics.topLevelWritableFieldCount and appears in the JSON output, giving users misleading data.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a7a0f2a. Configure here.

result.polymorphismClarity /= n;

return result;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty document shows perfect score with zero subscores

Low Severity

When an API has zero operations, computeDocumentScores returns agentReadiness: 100, but aggregateSubscores returns all-zero subscores (dividing zero sums by 1). The stylish output displays a perfect 100/100 Agent Readiness alongside every subscore bar at 0%, which is contradictory and confusing to users.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a7a0f2a. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants