Skip to content

Commit 0e328aa

Browse files
committed
refactor: address PR review feedback for score command
- 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
1 parent 2c19efb commit 0e328aa

File tree

6 files changed

+241
-109
lines changed

6 files changed

+241
-109
lines changed

.changeset/add-score-command.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
'@redocly/cli': minor
33
---
44

5-
Added new `score` command that analyzes OpenAPI 3.x descriptions and produces integration simplicity and agent readiness scores (0-100). Reports normalized subscores, raw per-operation metrics, and top hotspot operations with human-readable explanations. Supports `--format=stylish` (default) and `--format=json` output.
5+
Added new `score` command that analyzes OpenAPI 3.x descriptions and produces integration simplicity and AI agent readiness scores (0-100). Reports normalized subscores, raw per-operation metrics, and top hotspot operations with human-readable explanations. Supports `--format=stylish` (default) and `--format=json` output.

docs/@v2/commands/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ API management commands:
1515

1616
- [`bundle`](bundle.md) Bundle API description.
1717
- [`join`](join.md) Join API descriptions [experimental feature].
18-
- [`score`](score.md) Score an API for integration simplicity and agent readiness.
18+
- [`score`](score.md) Score an API for integration simplicity and AI agent readiness.
1919
- [`split`](split.md) Split API description into a multi-file structure.
2020
- [`stats`](stats.md) Gather statistics for a document.
2121

docs/@v2/commands/score.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ The following raw metrics are collected per operation and aggregated across the
3131
| Constraint coverage | Count of constraining keywords (`enum`, `format`, `pattern`, `minimum`, `maximum`, `minLength`, `maxLength`, `discriminator`, etc.). |
3232
| Request/response example coverage | Whether request and response media types include `example` or `examples`. |
3333
| Structured error response coverage | How many 4xx/5xx responses include a content schema or meaningful description. |
34+
| Security scheme coverage | Whether operations reference documented security schemes with descriptions. |
3435
| Workflow dependency depth | Inferred from shared `$ref` usage across operations; deeper shared-schema graphs indicate more tightly coupled workflows. |
3536

3637
### Subscores
@@ -109,7 +110,7 @@ redocly score --config=./another/directory/config.yaml
109110

110111
The default output format shows a human-readable summary in your terminal:
111112

112-
<pre>
113+
```sh
113114
Scores
114115

115116
Integration Simplicity: 72.5/100
@@ -137,7 +138,7 @@ The default output format shows a human-readable summary in your terminal:
137138
Integration Simplicity: 52.1 Agent Readiness: 44.0
138139
- Polymorphism (anyOf) without discriminator (3 anyOf)
139140
- No structured error responses (4xx/5xx)
140-
</pre>
141+
```
141142

142143
#### JSON output
143144

packages/cli/src/commands/score/__tests__/index.test.ts

Lines changed: 113 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ vi.mock('@redocly/openapi-core', async (importOriginal) => {
44
...actual,
55
bundle: vi.fn(),
66
detectSpec: vi.fn(),
7+
BaseResolver: vi.fn(),
8+
resolveDocument: vi.fn(),
9+
normalizeTypes: vi.fn(),
10+
getTypes: vi.fn(),
11+
normalizeVisitors: vi.fn(),
12+
walkDocument: vi.fn(),
713
logger: { output: vi.fn(), info: vi.fn(), error: vi.fn() },
814
};
915
});
@@ -13,67 +19,107 @@ vi.mock('../../../utils/miscellaneous.js', () => ({
1319
printExecutionTime: vi.fn(),
1420
}));
1521

16-
import { bundle, detectSpec, logger } from '@redocly/openapi-core';
22+
vi.mock('../collectors/document-metrics.js', async (importOriginal) => {
23+
const actual = (await importOriginal()) as any;
24+
return {
25+
...actual,
26+
createScoreAccumulator: vi.fn(),
27+
createScoreVisitor: vi.fn(),
28+
};
29+
});
30+
31+
import {
32+
bundle,
33+
detectSpec,
34+
logger,
35+
normalizeTypes,
36+
resolveDocument,
37+
normalizeVisitors,
38+
walkDocument,
39+
} from '@redocly/openapi-core';
1740

1841
import { getFallbackApisOrExit } from '../../../utils/miscellaneous.js';
42+
import {
43+
createScoreAccumulator,
44+
createScoreVisitor,
45+
type ScoreAccumulator,
46+
} from '../collectors/document-metrics.js';
1947
import { handleScore, type ScoreArgv } from '../index.js';
48+
import type { OperationMetrics } from '../types.js';
2049

2150
const mockedBundle = vi.mocked(bundle);
2251
const mockedDetectSpec = vi.mocked(detectSpec);
2352
const mockedGetFallback = vi.mocked(getFallbackApisOrExit);
2453
const mockOutput = vi.mocked(logger.output);
25-
const mockInfo = vi.mocked(logger.info);
2654
const mockError = vi.mocked(logger.error);
27-
28-
function makeMinimalDoc(overrides: Record<string, any> = {}) {
55+
const mockedCreateAccumulator = vi.mocked(createScoreAccumulator);
56+
const mockedCreateVisitor = vi.mocked(createScoreVisitor);
57+
const mockedNormalizeTypes = vi.mocked(normalizeTypes);
58+
const mockedResolveDocument = vi.mocked(resolveDocument);
59+
const mockedNormalizeVisitors = vi.mocked(normalizeVisitors);
60+
const mockedWalkDocument = vi.mocked(walkDocument);
61+
62+
function makeTestMetrics(overrides: Partial<OperationMetrics> = {}): OperationMetrics {
2963
return {
30-
openapi: '3.0.0',
31-
info: { title: 'Test', version: '1.0.0' },
32-
paths: {
33-
'/items': {
34-
get: {
35-
operationId: 'listItems',
36-
description: 'List items',
37-
responses: {
38-
'200': {
39-
description: 'OK',
40-
content: {
41-
'application/json': {
42-
schema: { type: 'object', properties: { id: { type: 'string' } } },
43-
example: { id: '1' },
44-
},
45-
},
46-
},
47-
},
48-
},
49-
},
50-
},
64+
path: '/items',
65+
method: 'get',
66+
operationId: 'listItems',
67+
parameterCount: 0,
68+
requiredParameterCount: 0,
69+
paramsWithDescription: 0,
70+
requestBodyPresent: false,
71+
topLevelWritableFieldCount: 0,
72+
maxRequestSchemaDepth: 0,
73+
maxResponseSchemaDepth: 1,
74+
polymorphismCount: 0,
75+
anyOfCount: 0,
76+
hasDiscriminator: false,
77+
propertyCount: 1,
78+
operationDescriptionPresent: true,
79+
schemaPropertiesWithDescription: 0,
80+
totalSchemaProperties: 1,
81+
constraintCount: 0,
82+
requestExamplePresent: false,
83+
responseExamplePresent: true,
84+
structuredErrorResponseCount: 0,
85+
totalErrorResponses: 0,
86+
ambiguousIdentifierCount: 0,
87+
refsUsed: new Set(),
5188
...overrides,
5289
};
5390
}
5491

92+
function makeAccumulator(ops: Map<string, OperationMetrics> = new Map()): ScoreAccumulator {
93+
return { operations: ops, currentPath: '', pathLevelParams: [] };
94+
}
95+
5596
function createArgs(overrides: Partial<ScoreArgv> = {}) {
5697
return {
5798
argv: { format: 'stylish' as const, ...overrides } as ScoreArgv,
58-
config: {} as any,
99+
config: { extendTypes: (types: any) => types, resolve: {} } as any,
59100
collectSpecData: vi.fn(),
60101
};
61102
}
62103

63104
describe('handleScore', () => {
64105
beforeEach(() => {
65106
mockOutput.mockClear();
66-
mockInfo.mockClear();
67107
mockError.mockClear();
68108
mockedGetFallback.mockResolvedValue([{ path: 'test.yaml' }] as any);
109+
mockedBundle.mockResolvedValue({ bundle: { parsed: {} } } as any);
110+
mockedDetectSpec.mockReturnValue('oas3_0');
111+
mockedNormalizeTypes.mockReturnValue({ Root: {} } as any);
112+
mockedResolveDocument.mockResolvedValue(new Map() as any);
113+
mockedNormalizeVisitors.mockReturnValue([] as any);
114+
mockedWalkDocument.mockImplementation(() => {});
115+
mockedCreateVisitor.mockReturnValue({} as any);
116+
mockedCreateAccumulator.mockReturnValue(
117+
makeAccumulator(new Map([['listItems', makeTestMetrics()]]))
118+
);
69119
process.exitCode = undefined;
70120
});
71121

72122
it('should produce stylish output for a valid oas3 document', async () => {
73-
const doc = makeMinimalDoc();
74-
mockedBundle.mockResolvedValue({ bundle: { parsed: doc } } as any);
75-
mockedDetectSpec.mockReturnValue('oas3_0');
76-
77123
await handleScore(createArgs());
78124

79125
const output = mockOutput.mock.calls.map(([s]: [string]) => s).join('');
@@ -82,10 +128,6 @@ describe('handleScore', () => {
82128
});
83129

84130
it('should produce JSON output when format is json', async () => {
85-
const doc = makeMinimalDoc();
86-
mockedBundle.mockResolvedValue({ bundle: { parsed: doc } } as any);
87-
mockedDetectSpec.mockReturnValue('oas3_0');
88-
89131
await handleScore(createArgs({ format: 'json' }));
90132

91133
const output = mockOutput.mock.calls[0][0];
@@ -96,7 +138,6 @@ describe('handleScore', () => {
96138
});
97139

98140
it('should reject non-oas3 documents', async () => {
99-
mockedBundle.mockResolvedValue({ bundle: { parsed: { swagger: '2.0' } } } as any);
100141
mockedDetectSpec.mockReturnValue('oas2');
101142

102143
await handleScore(createArgs());
@@ -108,7 +149,7 @@ describe('handleScore', () => {
108149
});
109150

110151
it('should call collectSpecData', async () => {
111-
const doc = makeMinimalDoc();
152+
const doc = { openapi: '3.0.0' };
112153
mockedBundle.mockResolvedValue({ bundle: { parsed: doc } } as any);
113154
mockedDetectSpec.mockReturnValue('oas3_1');
114155

@@ -119,9 +160,7 @@ describe('handleScore', () => {
119160
});
120161

121162
it('should handle document with no operations', async () => {
122-
const doc = makeMinimalDoc({ paths: {} });
123-
mockedBundle.mockResolvedValue({ bundle: { parsed: doc } } as any);
124-
mockedDetectSpec.mockReturnValue('oas3_0');
163+
mockedCreateAccumulator.mockReturnValue(makeAccumulator());
125164

126165
await handleScore(createArgs());
127166

@@ -130,34 +169,23 @@ describe('handleScore', () => {
130169
});
131170

132171
it('should handle document with multiple operations', async () => {
133-
const doc = makeMinimalDoc({
134-
paths: {
135-
'/items': {
136-
get: {
137-
operationId: 'listItems',
138-
description: 'List items',
139-
responses: { '200': { description: 'OK' } },
140-
},
141-
post: {
142-
operationId: 'createItem',
143-
description: 'Create item',
144-
requestBody: {
145-
content: {
146-
'application/json': {
147-
schema: { type: 'object', properties: { name: { type: 'string' } } },
148-
},
149-
},
150-
},
151-
responses: {
152-
'201': { description: 'Created' },
153-
'400': { description: 'Bad Request' },
154-
},
155-
},
156-
},
157-
},
158-
});
159-
mockedBundle.mockResolvedValue({ bundle: { parsed: doc } } as any);
160-
mockedDetectSpec.mockReturnValue('oas3_0');
172+
mockedCreateAccumulator.mockReturnValue(
173+
makeAccumulator(
174+
new Map([
175+
['listItems', makeTestMetrics()],
176+
[
177+
'createItem',
178+
makeTestMetrics({
179+
path: '/items',
180+
method: 'post',
181+
operationId: 'createItem',
182+
requestBodyPresent: true,
183+
operationDescriptionPresent: true,
184+
}),
185+
],
186+
])
187+
)
188+
);
161189

162190
await handleScore(createArgs({ format: 'json' }));
163191

@@ -168,25 +196,23 @@ describe('handleScore', () => {
168196
});
169197

170198
it('should include hotspots in output', async () => {
171-
const doc = makeMinimalDoc({
172-
paths: {
173-
'/complex': {
174-
post: {
175-
operationId: 'complexOp',
176-
parameters: Array.from({ length: 10 }, (_, i) => ({
177-
name: `param${i}`,
178-
in: 'query',
179-
})),
180-
requestBody: {
181-
content: { 'application/json': { schema: { type: 'object' } } },
182-
},
183-
responses: { '200': { description: 'OK' } },
184-
},
185-
},
186-
},
187-
});
188-
mockedBundle.mockResolvedValue({ bundle: { parsed: doc } } as any);
189-
mockedDetectSpec.mockReturnValue('oas3_0');
199+
mockedCreateAccumulator.mockReturnValue(
200+
makeAccumulator(
201+
new Map([
202+
[
203+
'complexOp',
204+
makeTestMetrics({
205+
path: '/complex',
206+
method: 'post',
207+
operationId: 'complexOp',
208+
parameterCount: 10,
209+
requestBodyPresent: true,
210+
operationDescriptionPresent: false,
211+
}),
212+
],
213+
])
214+
)
215+
);
190216

191217
await handleScore(createArgs());
192218

0 commit comments

Comments
 (0)