Repository: Redocly/redocly-cli
Pull request #1811 (2024-11-25 06:35:37)

fix: resolve problem with severity change for configurable rules
## What/Why/How?

Severity of configurable rules could not be overridden without full rule copy:

**base.yaml**
```yaml
rules:
  rule/abc:
    severity: error
    # ...
```

**redocly.yaml**
```yaml
extends:
  - base.yaml
rules:
  rule/abc: warn
```

The above did not work.


## Reference
Reported by a customer.

Related to https://github.com/Redocly/redocly/issues/12111

## Testing

Covered with unit test.

## Screenshots (optional)

## Check yourself

- [ ] Code changed? - Tested with redoc/reference-docs/workflows (internal)
- [x] All new/updated code is covered with tests
- [ ] New package installed? - Tested in different environments (browser/node)

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines



Patch:
diff --git a/.changeset/fuzzy-crews-boil.md b/.changeset/fuzzy-crews-boil.md
new file mode 100644
index 00000000..f73c1287
--- /dev/null
+++ b/.changeset/fuzzy-crews-boil.md
@@ -0,0 +1,6 @@
+---
+"@redocly/openapi-core": patch
+"@redocly/cli": patch
+---
+
+Resolved an issue where overrides for the severity of configurable rules were ignored.
diff --git a/packages/core/src/config/utils.ts b/packages/core/src/config/utils.ts
index 92904d4e..13a64833 100644
--- a/packages/core/src/config/utils.ts
+++ b/packages/core/src/config/utils.ts
@@ -1,5 +1,6 @@
 import {
-  assignExisting,
+  assignOnlyExistingConfig,
+  assignConfig,
   isDefined,
   isTruthy,
   showErrorForDeprecatedField,
@@ -198,47 +199,47 @@ export function mergeExtends(rulesConfList: ResolvedStyleguideConfig[]) {
       );
     }
 
-    Object.assign(result.rules, rulesConf.rules);
-    Object.assign(result.oas2Rules, rulesConf.oas2Rules);
-    assignExisting(result.oas2Rules, rulesConf.rules || {});
-    Object.assign(result.oas3_0Rules, rulesConf.oas3_0Rules);
-    assignExisting(result.oas3_0Rules, rulesConf.rules || {});
-    Object.assign(result.oas3_1Rules, rulesConf.oas3_1Rules);
-    assignExisting(result.oas3_1Rules, rulesConf.rules || {});
-    Object.assign(result.async2Rules, rulesConf.async2Rules);
-    assignExisting(result.async2Rules, rulesConf.rules || {});
-    Object.assign(result.async3Rules, rulesConf.async3Rules);
-    assignExisting(result.async3Rules, rulesConf.rules || {});
-    Object.assign(result.arazzoRules, rulesConf.arazzoRules);
-    assignExisting(result.arazzoRules, rulesConf.rules || {});
-
-    Object.assign(result.preprocessors, rulesConf.preprocessors);
-    Object.assign(result.oas2Preprocessors, rulesConf.oas2Preprocessors);
-    assignExisting(result.oas2Preprocessors, rulesConf.preprocessors || {});
-    Object.assign(result.oas3_0Preprocessors, rulesConf.oas3_0Preprocessors);
-    assignExisting(result.oas3_0Preprocessors, rulesConf.preprocessors || {});
-    Object.assign(result.oas3_1Preprocessors, rulesConf.oas3_1Preprocessors);
-    assignExisting(result.oas3_1Preprocessors, rulesConf.preprocessors || {});
-    Object.assign(result.async2Preprocessors, rulesConf.async2Preprocessors);
-    assignExisting(result.async2Preprocessors, rulesConf.preprocessors || {});
-    Object.assign(result.async3Preprocessors, rulesConf.async3Preprocessors);
-    assignExisting(result.async3Preprocessors, rulesConf.preprocessors || {});
-    Object.assign(result.arazzoPreprocessors, rulesConf.arazzoPreprocessors);
-    assignExisting(result.arazzoPreprocessors, rulesConf.preprocessors || {});
-
-    Object.assign(result.decorators, rulesConf.decorators);
-    Object.assign(result.oas2Decorators, rulesConf.oas2Decorators);
-    assignExisting(result.oas2Decorators, rulesConf.decorators || {});
-    Object.assign(result.oas3_0Decorators, rulesConf.oas3_0Decorators);
-    assignExisting(result.oas3_0Decorators, rulesConf.decorators || {});
-    Object.assign(result.oas3_1Decorators, rulesConf.oas3_1Decorators);
-    assignExisting(result.oas3_1Decorators, rulesConf.decorators || {});
-    Object.assign(result.async2Decorators, rulesConf.async2Decorators);
-    assignExisting(result.async2Decorators, rulesConf.decorators || {});
-    Object.assign(result.async3Decorators, rulesConf.async3Decorators);
-    assignExisting(result.async3Decorators, rulesConf.decorators || {});
-    Object.assign(result.arazzoDecorators, rulesConf.arazzoDecorators);
-    assignExisting(result.arazzoDecorators, rulesConf.decorators || {});
+    assignConfig(result.rules, rulesConf.rules);
+    assignConfig(result.oas2Rules, rulesConf.oas2Rules);
+    assignOnlyExistingConfig(result.oas2Rules, rulesConf.rules);
+    assignConfig(result.oas3_0Rules, rulesConf.oas3_0Rules);
+    assignOnlyExistingConfig(result.oas3_0Rules, rulesConf.rules);
+    assignConfig(result.oas3_1Rules, rulesConf.oas3_1Rules);
+    assignOnlyExistingConfig(result.oas3_1Rules, rulesConf.rules);
+    assignConfig(result.async2Rules, rulesConf.async2Rules);
+    assignOnlyExistingConfig(result.async2Rules, rulesConf.rules);
+    assignConfig(result.async3Rules, rulesConf.async3Rules);
+    assignOnlyExistingConfig(result.async3Rules, rulesConf.rules);
+    assignConfig(result.arazzoRules, rulesConf.arazzoRules);
+    assignOnlyExistingConfig(result.arazzoRules, rulesConf.rules);
+
+    assignConfig(result.preprocessors, rulesConf.preprocessors);
+    assignConfig(result.oas2Preprocessors, rulesConf.oas2Preprocessors);
+    assignOnlyExistingConfig(result.oas2Preprocessors, rulesConf.preprocessors);
+    assignConfig(result.oas3_0Preprocessors, rulesConf.oas3_0Preprocessors);
+    assignOnlyExistingConfig(result.oas3_0Preprocessors, rulesConf.preprocessors);
+    assignConfig(result.oas3_1Preprocessors, rulesConf.oas3_1Preprocessors);
+    assignOnlyExistingConfig(result.oas3_1Preprocessors, rulesConf.preprocessors);
+    assignConfig(result.async2Preprocessors, rulesConf.async2Preprocessors);
+    assignOnlyExistingConfig(result.async2Preprocessors, rulesConf.preprocessors);
+    assignConfig(result.async3Preprocessors, rulesConf.async3Preprocessors);
+    assignOnlyExistingConfig(result.async3Preprocessors, rulesConf.preprocessors);
+    assignConfig(result.arazzoPreprocessors, rulesConf.arazzoPreprocessors);
+    assignOnlyExistingConfig(result.arazzoPreprocessors, rulesConf.preprocessors);
+
+    assignConfig(result.decorators, rulesConf.decorators);
+    assignConfig(result.oas2Decorators, rulesConf.oas2Decorators);
+    assignOnlyExistingConfig(result.oas2Decorators, rulesConf.decorators);
+    assignConfig(result.oas3_0Decorators, rulesConf.oas3_0Decorators);
+    assignOnlyExistingConfig(result.oas3_0Decorators, rulesConf.decorators);
+    assignConfig(result.oas3_1Decorators, rulesConf.oas3_1Decorators);
+    assignOnlyExistingConfig(result.oas3_1Decorators, rulesConf.decorators);
+    assignConfig(result.async2Decorators, rulesConf.async2Decorators);
+    assignOnlyExistingConfig(result.async2Decorators, rulesConf.decorators);
+    assignConfig(result.async3Decorators, rulesConf.async3Decorators);
+    assignOnlyExistingConfig(result.async3Decorators, rulesConf.decorators);
+    assignConfig(result.arazzoDecorators, rulesConf.arazzoDecorators);
+    assignOnlyExistingConfig(result.arazzoDecorators, rulesConf.decorators);
 
     result.plugins!.push(...(rulesConf.plugins || []));
     result.pluginPaths!.push(...(rulesConf.pluginPaths || []));
diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts
index 994319dd..bce689d9 100644
--- a/packages/core/src/utils.ts
+++ b/packages/core/src/utils.ts
@@ -196,9 +196,30 @@ export function isNotString<T>(value: string | T): value is T {
   return !isString(value);
 }
 
-export function assignExisting<T>(target: Record<string, T>, obj: Record<string, T>) {
+export const assignConfig = <T extends string | { severity?: string }>(
+  target: Record<string, T>,
+  obj?: Record<string, T>
+) => {
+  if (!obj) return;
   for (const k of Object.keys(obj)) {
-    if (target.hasOwnProperty(k)) {
+    if (isPlainObject(target[k]) && typeof obj[k] === 'string') {
+      target[k].severity = obj[k];
+    } else {
+      target[k] = obj[k];
+    }
+  }
+};
+
+export function assignOnlyExistingConfig<T extends string | { severity?: string }>(
+  target: Record<string, T>,
+  obj?: Record<string, T>
+) {
+  if (!obj) return;
+  for (const k of Object.keys(obj)) {
+    if (!target.hasOwnProperty(k)) continue;
+    if (isPlainObject(target[k]) && typeof obj[k] === 'string') {
+      target[k].severity = obj[k];
+    } else {
       target[k] = obj[k];
     }
   }


Test patch:
diff --git a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap
index d28851a8..049cd5a6 100644
--- a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap
+++ b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap
@@ -63,7 +63,10 @@ exports[`resolveConfig should ignore minimal from the root and read local file 1
     "component-name-unique": "off",
     "no-empty-servers": "error",
     "no-example-value-and-externalValue": "error",
-    "no-invalid-media-type-examples": "error",
+    "no-invalid-media-type-examples": {
+      "allowAdditionalProperties": false,
+      "severity": "error",
+    },
     "no-server-example.com": "warn",
     "no-server-trailing-slash": "error",
     "no-server-variables-empty-enum": "error",
@@ -208,7 +211,10 @@ exports[`resolveStyleguideConfig should resolve extends with local file config w
     "component-name-unique": "off",
     "no-empty-servers": "error",
     "no-example-value-and-externalValue": "error",
-    "no-invalid-media-type-examples": "warn",
+    "no-invalid-media-type-examples": {
+      "allowAdditionalProperties": false,
+      "severity": "warn",
+    },
     "no-server-example.com": "warn",
     "no-server-trailing-slash": "error",
     "no-server-variables-empty-enum": "error",
diff --git a/packages/core/src/config/__tests__/utils.test.ts b/packages/core/src/config/__tests__/utils.test.ts
index cd5c6b1d..b775d820 100644
--- a/packages/core/src/config/__tests__/utils.test.ts
+++ b/packages/core/src/config/__tests__/utils.test.ts
@@ -202,3 +202,24 @@ describe('transformConfig', () => {
     });
   });
 });
+
+describe('mergeExtends', () => {
+  it('should work with empty extends', () => {
+    expect(utils.mergeExtends([]).rules).toEqual({});
+  });
+
+  it('should work with configurable rules changing severity', () => {
+    expect(
+      utils.mergeExtends([
+        {
+          rules: { 'rule/abc': { severity: 'error', subject: 'Operation' } },
+        },
+        {
+          rules: { 'rule/abc': 'warn' },
+        },
+      ]).rules
+    ).toEqual({
+      'rule/abc': { severity: 'warn', subject: 'Operation' },
+    });
+  });
+});