Repository: Redocly/redocly-cli
Pull request #903 (2022-10-10 14:59:20)

fix: detect api from the list by the root file match
## What/Why/How?

If `redocly-cli lint` was run with path to the file as argument it was not detecting proper overrides from `apis` config.

## Reference

Found it while fixing https://github.com/Redocly/redocly-cli/issues/899

## Testing

Added a unit test.

## 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


github-actions[bot]: <!-- jest coverage report action for options with hash 3339ae53a38c44382c0fb4491bdb282d -->

## Coverage report 



| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category   | Percentage | Covered / Total |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------- | :--------- | :-------------: |
|                                                                           🟡                                                                           | Statements | 73.41%     |    3282/4471    |
|                                                                           🟡                                                                           | Branches   | 65.13%     |    1847/2836    |
|                                                                           🟡                                                                           | Functions  | 66.35%     |     554/835     |
|                                                                           🟡                                                                           | Lines      | 73.33%     |    3049/4158    |


## Test suite run success
509 tests passing in 85 suites.

<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from 29e8db4e2871c642cd1a1bce73184b082831f02a</p>

RomanHotsiy: > What happens if the same root file is used for multiple APIs:

The first match is used.

Patch:
diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts
index 8a7dea6d..b7aef7bd 100644
--- a/packages/cli/src/utils.ts
+++ b/packages/cli/src/utils.ts
@@ -48,7 +48,14 @@ function isNotEmptyArray<T>(args?: T[]): boolean {
 function getAliasOrPath(config: Config, aliasOrPath: string): Entrypoint {
   return config.apis[aliasOrPath]
     ? { path: config.apis[aliasOrPath]?.root, alias: aliasOrPath }
-    : { path: aliasOrPath };
+    : {
+        path: aliasOrPath,
+        // find alias by path, take the first match
+        alias:
+          Object.entries(config.apis).find(([_alias, api]) => {
+            return path.resolve(api.root) === path.resolve(aliasOrPath);
+          })?.[0] ?? undefined,
+      };
 }
 
 async function expandGlobsInEntrypoints(args: string[], config: Config) {
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;
+}
diff --git a/resources/.redocly.yaml b/resources/.redocly.yaml
index b5542b61..0e008414 100644
--- a/resources/.redocly.yaml
+++ b/resources/.redocly.yaml
@@ -1,29 +1,7 @@
-apiDefinitions:
-  main: ./petstore-with-errors.yaml
-lint:
-  plugins:
-    - './local-plugin.js'
-
-  extends:
-    - recommended
-    - local/all
-  rules:
-    operation-2xx-response: warn
-    no-invalid-media-type-examples: error
-    # operation-description: error
-    path-http-verbs-order: error
-    boolean-parameter-prefixes: off
-  preprocessors:
-    # local/duplicate-description: on
-  decorators:
-    # local/inject-x-stats: on
-referenceDocs:
-  showConsole: true
-  layout:
-    scope: section
-  routingStrategy: browser
-  theme:
-    rightPanel:
-      backgroundColor: '#263238'
-    links:
-      color: '#6CC496'
\ No newline at end of file
+apis:
+  repro@bug:
+    root: ./pets.yaml
+    decorators: {}
+    extends: []
+extends:
+  - recommended
\ No newline at end of file


Test patch:
diff --git a/packages/cli/src/__tests__/utils.test.ts b/packages/cli/src/__tests__/utils.test.ts
index 17ab40cf..a87d7384 100644
--- a/packages/cli/src/__tests__/utils.test.ts
+++ b/packages/cli/src/__tests__/utils.test.ts
@@ -1,5 +1,5 @@
 import { Totals } from '@redocly/openapi-core';
-import { isSubdir, pathToFilename, printConfigLintTotals } from '../utils';
+import { getFallbackApisOrExit, isSubdir, pathToFilename, printConfigLintTotals } from '../utils';
 import { red, yellow } from 'colorette';
 
 jest.mock('os');
@@ -51,6 +51,19 @@ describe('pathToFilename', () => {
   });
 });
 
+describe('getFallbackApisOrExit', () => {
+  it('should find alias by filename', async () => {
+    const entry = await getFallbackApisOrExit(['./test.yaml'], {
+      apis: {
+        main: {
+          root: 'test.yaml',
+        },
+      },
+    } as any);
+    expect(entry).toEqual([{ path: './test.yaml', alias: 'main' }]);
+  });
+});
+
 describe('printConfigLintTotals', () => {
   const totalProblemsMock: Totals = {
     errors: 1,
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
+  });
 });