Skip to content
This repository was archived by the owner on Jan 12, 2026. It is now read-only.

Commit 0b5442e

Browse files
Enhancement: exclude_operations_with_extension support for specific values (#2887)
1 parent 24e500f commit 0b5442e

File tree

3 files changed

+182
-13
lines changed

3 files changed

+182
-13
lines changed

projects/standard-rulesets/src/breaking-changes/__tests__/breaking-changes.test.ts

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ describe('fromOpticConfig', () => {
1111
expect(out)
1212
.toEqual(`- ruleset/breaking-changes/exclude_operations_with_extension must be string
1313
- ruleset/breaking-changes/exclude_operations_with_extension must be array
14+
- ruleset/breaking-changes/exclude_operations_with_extension must be array
1415
- ruleset/breaking-changes/exclude_operations_with_extension must match exactly one schema in oneOf`);
1516
});
1617

@@ -34,6 +35,18 @@ describe('fromOpticConfig', () => {
3435
expect(out).toBeInstanceOf(BreakingChangesRuleset);
3536
});
3637

38+
test('valid config with exclude_operations_with_extension as object', async () => {
39+
const out = await BreakingChangesRuleset.fromOpticConfig({
40+
exclude_operations_with_extension: [
41+
{
42+
'x-stability': ['beta', 'alpha', 'draft'],
43+
},
44+
],
45+
});
46+
47+
expect(out).toBeInstanceOf(BreakingChangesRuleset);
48+
});
49+
3750
test('does not throw breaking change if semvar has updated', async () => {
3851
const out = await BreakingChangesRuleset.fromOpticConfig({
3952
skip_when_major_version_changes: true,
@@ -1234,7 +1247,7 @@ describe('breaking changes ruleset', () => {
12341247
});
12351248

12361249
describe('breaking change ruleset configuration', () => {
1237-
test('breaking changes applies a matches function', async () => {
1250+
test('breaking changes applies a matches function for string extension', async () => {
12381251
const beforeJson: OpenAPIV3.Document = {
12391252
...TestHelpers.createEmptySpec(),
12401253
paths: {
@@ -1261,13 +1274,129 @@ describe('breaking change ruleset configuration', () => {
12611274
};
12621275
const results = await TestHelpers.runRulesWithInputs(
12631276
[
1264-
BreakingChangesRuleset.fromOpticConfig({
1277+
await BreakingChangesRuleset.fromOpticConfig({
12651278
exclude_operations_with_extension: 'x-legacy',
12661279
}) as any,
12671280
],
12681281
beforeJson,
12691282
afterJson
12701283
);
1271-
expect(results.length === 0).toBe(true);
1284+
expect(results.length).toBe(0);
1285+
});
1286+
1287+
test('breaking changes applies a matches function for object extension value match', async () => {
1288+
const beforeJson: OpenAPIV3.Document = {
1289+
...TestHelpers.createEmptySpec(),
1290+
paths: {
1291+
'/api/users': {
1292+
get: {
1293+
responses: {},
1294+
},
1295+
post: {
1296+
'x-stability-level': 'draft',
1297+
responses: {},
1298+
} as any,
1299+
},
1300+
},
1301+
};
1302+
const afterJson: OpenAPIV3.Document = {
1303+
...TestHelpers.createEmptySpec(),
1304+
paths: {
1305+
'/api/users': {
1306+
get: {
1307+
responses: {},
1308+
},
1309+
},
1310+
},
1311+
};
1312+
const results = await TestHelpers.runRulesWithInputs(
1313+
[
1314+
await BreakingChangesRuleset.fromOpticConfig({
1315+
exclude_operations_with_extension: [
1316+
{ 'x-stability-level': ['draft'] },
1317+
],
1318+
}) as any,
1319+
],
1320+
beforeJson,
1321+
afterJson
1322+
);
1323+
expect(results.length).toBe(0)
1324+
});
1325+
1326+
test('breaking changes applies a matches function for object extension value mismatch', async () => {
1327+
const beforeJson: OpenAPIV3.Document = {
1328+
...TestHelpers.createEmptySpec(),
1329+
paths: {
1330+
'/api/users': {
1331+
get: {
1332+
responses: {},
1333+
},
1334+
post: {
1335+
'x-stability-level': 'stable',
1336+
responses: {},
1337+
} as any,
1338+
},
1339+
},
1340+
};
1341+
const afterJson: OpenAPIV3.Document = {
1342+
...TestHelpers.createEmptySpec(),
1343+
paths: {
1344+
'/api/users': {
1345+
get: {
1346+
responses: {},
1347+
},
1348+
},
1349+
},
1350+
};
1351+
const results = await TestHelpers.runRulesWithInputs(
1352+
[
1353+
await BreakingChangesRuleset.fromOpticConfig({
1354+
exclude_operations_with_extension: [
1355+
{ 'x-stability-level': ['draft'] },
1356+
],
1357+
}) as any,
1358+
],
1359+
beforeJson,
1360+
afterJson
1361+
);
1362+
expect(results.length).toEqual(1);
1363+
});
1364+
1365+
test('breaking changes applies a matches function for object extension value missing', async () => {
1366+
const beforeJson: OpenAPIV3.Document = {
1367+
...TestHelpers.createEmptySpec(),
1368+
paths: {
1369+
'/api/users': {
1370+
get: {
1371+
responses: {},
1372+
},
1373+
post: {
1374+
responses: {},
1375+
} as any,
1376+
},
1377+
},
1378+
};
1379+
const afterJson: OpenAPIV3.Document = {
1380+
...TestHelpers.createEmptySpec(),
1381+
paths: {
1382+
'/api/users': {
1383+
get: {
1384+
responses: {},
1385+
},
1386+
},
1387+
},
1388+
};
1389+
const results = await TestHelpers.runRulesWithInputs(
1390+
[
1391+
await BreakingChangesRuleset.fromOpticConfig({
1392+
exclude_operations_with_extension: [
1393+
{ 'x-stability-level': ['draft'] },
1394+
],
1395+
}) as any,
1396+
],
1397+
beforeJson,
1398+
afterJson
1399+
);
1400+
expect(results.length).toEqual(1);
12721401
});
12731402
});

projects/standard-rulesets/src/breaking-changes/index.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { preventResponsePropertyOptional } from './preventResponsePropertyOption
66
import { preventResponsePropertyRemoval } from './preventResponsePropertyRemoval';
77
import { preventResponsePropertyTypeChange } from './preventResponsePropertyTypeChange';
88
import { preventResponseStatusCodeRemoval } from './preventResponseStatusCodeRemoval';
9+
910
import {
1011
preventQueryParameterEnumBreak,
1112
preventCookieParameterEnumBreak,
@@ -36,7 +37,7 @@ import { preventResponseNarrowingInUnionTypes } from './preventResponseNarrowing
3637
import { excludeOperationWithExtensionMatches } from '../utils';
3738

3839
type YamlConfig = {
39-
exclude_operations_with_extension?: string | string[];
40+
exclude_operations_with_extension?: string | string[] | { [key: string]: string[] }[];
4041
skip_when_major_version_changes?: boolean;
4142
docs_link?: string;
4243
severity?: SeverityText;
@@ -47,7 +48,21 @@ const configSchema = {
4748
type: 'object',
4849
properties: {
4950
exclude_operations_with_extension: {
50-
oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
51+
oneOf: [
52+
{ type: 'string' },
53+
{ type: 'array', items: { type: 'string' }},
54+
{
55+
type: 'array',
56+
items: {
57+
type: 'object',
58+
minProperties: 1,
59+
additionalProperties: {
60+
type: 'array',
61+
items: { type: 'string' },
62+
},
63+
},
64+
},
65+
],
5166
},
5267
skip_when_major_version_changes: {
5368
type: 'boolean',

projects/standard-rulesets/src/utils.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,40 @@ function extensionIsTruthy(extension: any) {
1010
}
1111

1212
export const excludeOperationWithExtensionMatches = (
13-
excludeOperationWithExtensions: string | string[]
13+
excludeOperationWithExtensions: string | string[] | { [key: string]: string[] }[]
1414
) => {
1515
return (context: RuleContext): boolean => {
16-
return Array.isArray(excludeOperationWithExtensions)
17-
? excludeOperationWithExtensions.some(
18-
(e) => !extensionIsTruthy((context.operation.raw as any)[e])
19-
)
20-
: !extensionIsTruthy(
21-
(context.operation.raw as any)[excludeOperationWithExtensions]
22-
);
16+
const operation = context.operation.raw as any;
17+
18+
// Case 1: A single extension string (e.g., 'x-legacy')
19+
if (typeof excludeOperationWithExtensions === 'string') {
20+
return !extensionIsTruthy(operation[excludeOperationWithExtensions]);
21+
}
22+
23+
// Case 2: An array of extensions
24+
if (Array.isArray(excludeOperationWithExtensions)) {
25+
for (const exclusion of excludeOperationWithExtensions) {
26+
// Case 2a: Array of strings (e.g., ['x-legacy', 'x-internal'])
27+
if (typeof exclusion === 'string') {
28+
if (extensionIsTruthy(operation[exclusion])) {
29+
return false; // Exclude if any extension is truthy
30+
}
31+
}
32+
// Case 2b: Array of objects (e.g., [{ 'x-stability': ['beta'] }])
33+
else if (typeof exclusion === 'object' && exclusion !== null) {
34+
for (const [key, values] of Object.entries(exclusion)) {
35+
const extensionValue = operation[key];
36+
if (
37+
extensionValue &&
38+
values.includes(String(extensionValue))
39+
) {
40+
return false; // Exclude if the extension value matches
41+
}
42+
}
43+
}
44+
}
45+
}
46+
return true; // Include by default
2347
};
2448
};
49+

0 commit comments

Comments
 (0)