Repository: Redocly/redocly-cli (ts)

Issue:
Any API-specific override resets all root settings
**Describe the bug**

Defining any API-specific override results in all non-customized props not receiving the root values.

**To Reproduce**
Steps to reproduce the behavior:
1. Given this `redocly.yaml` file
    ```yaml
        apis:
          repro@bug: 
            root: openapi.yaml
            decorators: {}
        extends:
          - recommended
    ```
2. And this OpenAPI file (without any Info license which will cause a warning with the `recommended` rules):
    ```yaml
    openapi: 3.1.0
    info:
      version: 1.0.0
      title: Example.com
      termsOfService: 'https://example.com/terms/'
      description: Sample.
    servers:
      - url: 'https://{tenant}/api/v1'
        variables:
          tenant:
            default: www
            description: Your tenant id
      - url: 'https://example.com/api/v1'
    paths: {}
    ```
3. Run this command with these arguments... `redocly lint`
4. See error (`Woohoo! Your OpenAPI definition is valid. 🎉` with no mention of any warning)

**Expected behavior**

I expected the above repro to say there is 1 warning.

I expect that `extends` defined at the root level of the configuration will apply to every API unless that API specifically overrides the `extends` property itself (even if it overrides a decorator as shown above).

For example, a specific override:

```yaml
        apis:
          repro@bug: 
            root: openapi.yaml
            decorators: {}
            extends: []
        extends:
          - recommended
```

**Logs**
<!-- If applicable, add logs to help explain your problem. -->
n/a

**Redocly Version(s)**
<!-- What version of Redocly CLI are you using? -->
- 1.0.0-beta.110
- 1.0.0-beta.109

It works in 1.0.0-beta.108 (although I needed to adjust the Redocly configuration file to reflect changes):
```yaml
apis:
  repro@bug: 
    root: openapi.yaml
    styleguide: 
      decorators: {}
styleguide: 
  extends:
    - recommended
```

**`Node.js` Version(s)**
16

**Additional context**

- Discovered when trying to reproduce #898 

Pull request description:
fix: fix api overrides not picking some values from root config
## What/Why/How?

The values were set to `undefined` by previous config processing in api override so it was overwriting all the root config values.

## Reference

Fixes #899 

## Testing

Added a test (this specific case was missing).

## Check yourself

- [x] Code is linted
- [ ] Tested with redoc/reference-docs/workflows
- [x] All new/updated code is covered with tests

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


Patch:
diff --git a/packages/core/src/config/config-resolvers.ts b/packages/core/src/config/config-resolvers.ts
index a2440c16..a2856c6f 100644
--- a/packages/core/src/config/config-resolvers.ts
+++ b/packages/core/src/config/config-resolvers.ts
@@ -1,5 +1,6 @@
 import * as path from 'path';
 import { isAbsoluteUrl } from '../ref-utils';
+import { pickDefined } from '../utils';
 import { BaseResolver } from '../resolve';
 import { defaultPlugin } from './builtIn';
 import {
@@ -355,7 +356,7 @@ function getMergedRawStyleguideConfig(
 ) {
   const resultLint = {
     ...rootStyleguideConfig,
-    ...apiStyleguideConfig,
+    ...pickDefined(apiStyleguideConfig),
     rules: { ...rootStyleguideConfig?.rules, ...apiStyleguideConfig?.rules },
     oas2Rules: { ...rootStyleguideConfig?.oas2Rules, ...apiStyleguideConfig?.oas2Rules },
     oas3_0Rules: { ...rootStyleguideConfig?.oas3_0Rules, ...apiStyleguideConfig?.oas3_0Rules },
diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts
index 14c0e8e2..20a6cc13 100644
--- a/packages/core/src/utils.ts
+++ b/packages/core/src/utils.ts
@@ -231,3 +231,16 @@ export function isTruthy<Truthy>(value: Truthy | Falsy): value is Truthy {
 export function identity<T>(value: T): T {
   return value;
 }
+
+export function pickDefined<T extends Record<string, unknown>>(
+  obj?: T
+): Record<string, unknown> | undefined {
+  if (!obj) return undefined;
+  const res: Record<string, unknown> = {};
+  for (const key in obj) {
+    if (obj[key] !== undefined) {
+      res[key] = obj[key];
+    }
+  }
+  return res;
+}


Test patch:
diff --git a/packages/core/src/__tests__/utils.test.ts b/packages/core/src/__tests__/utils.test.ts
index 34c0cb4c..83b6c28c 100644
--- a/packages/core/src/__tests__/utils.test.ts
+++ b/packages/core/src/__tests__/utils.test.ts
@@ -4,6 +4,7 @@ import {
   slash,
   getMatchingStatusCodeRange,
   doesYamlFileExist,
+  pickDefined,
 } from '../utils';
 import { isBrowser } from '../env';
 import * as fs from 'fs';
@@ -81,6 +82,16 @@ describe('utils', () => {
     });
   });
 
+  describe('pickDefined', () => {
+    it('returns undefined for undefined', () => {
+      expect(pickDefined(undefined)).toBeUndefined();
+    });
+
+    it('picks only defined values', () => {
+      expect(pickDefined({ a: 1, b: undefined, c: 3 })).toStrictEqual({ a: 1, c: 3 });
+    });
+  });
+
   describe('getMatchingStatusCodeRange', () => {
     it('should get the generalized form of status codes', () => {
       expect(getMatchingStatusCodeRange('202')).toEqual('2XX');
diff --git a/packages/core/src/config/__tests__/config-resolvers.test.ts b/packages/core/src/config/__tests__/config-resolvers.test.ts
index 7d0ad24b..7c1e76b7 100644
--- a/packages/core/src/config/__tests__/config-resolvers.test.ts
+++ b/packages/core/src/config/__tests__/config-resolvers.test.ts
@@ -464,4 +464,29 @@ describe('resolveConfig', () => {
     delete apis['petstore'].styleguide.pluginPaths;
     expect(apis['petstore'].styleguide).toMatchSnapshot();
   });
+
+  it('should default to the extends from the main config if no extends defined', async () => {
+    const rawConfig: RawConfig = {
+      apis: {
+        petstore: {
+          root: 'some/path',
+          styleguide: {
+            rules: {
+              'operation-4xx-response': 'error',
+            },
+          },
+        },
+      },
+      styleguide: {
+        extends: ['minimal'],
+        rules: {
+          'operation-2xx-response': 'warn',
+        },
+      },
+    };
+
+    const { apis } = await resolveConfig(rawConfig, configPath);
+    expect(apis['petstore'].styleguide.rules).toBeDefined();
+    expect(apis['petstore'].styleguide.rules?.['operation-2xx-response']).toEqual('warn'); // from minimal ruleset
+  });
 });