Repository: Redocly/redocly-cli (ts)

Issue:
For join, the same tag in multiple input files should not be seen as a conflict
**Problem**
I have two (or more) OAS files that use the same tag to tag some operations. Running `openapi join` results in an error about "Conflict on tags."

**Improved Solution**
I don't see why it would be considered a conflict if multiple schemas that you're trying to join together use the same tag names. Maybe display a warning, but it's not an irreconcilable crisis that should prevent the join from succeeding. Use the first (or the last) description of the tag--authors can figure that out. 

This is what is already done with components that are defined in multiple input files but are identical. It's not considered an error.

Perhaps have a command line option to let the user choose what to do if the same tag name is used in multiple files:
1. Allow it.
2. Error out.
3. Prefix with info property
4. Prefix with filename

**Describe alternatives you've considered**
I tried using prefix-tags-with-info-prop but that gave me different tags with goofy names, which was not at all useful.



Pull request description:
feat: join multiple files with same tags
## What/Why/How?
Currently `join` command does not tolerate the same tags from multiple files because of preventing the conflicts in `x-tagGroups`. Was added one more option `--skip-tags-check` which allows having the same tags in a resolved file but without `x-tagGroups` . `description` for the same `tag` with this option will be taken from the first match
 
## Reference
Closes #668 

## Testing

## Screenshots (optional)
After `join` command with tags conflicts
Was : 
<img width="992" alt="Screenshot 2022-06-08 at 12 56 28" src="https://user-images.githubusercontent.com/52038205/172591414-52c5f41e-adf7-4f50-9e08-8df42f1f330b.png">

Now: 
<img width="1055" alt="Screenshot 2022-06-08 at 12 52 46" src="https://user-images.githubusercontent.com/52038205/172591679-fa1d0a88-fadd-4bfa-a95c-3748937e957c.png">

with `--without-x-tag-groups` option: 

<img width="604" alt="Screenshot 2022-06-08 at 12 54 55" src="https://user-images.githubusercontent.com/52038205/172591818-1cbe2361-0dee-4bf3-b7d3-fe0ca6b53ac8.png">

## 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/cli/src/__mocks__/utils.ts b/packages/cli/src/__mocks__/utils.ts
index eb11e253..a8b2df4e 100644
--- a/packages/cli/src/__mocks__/utils.ts
+++ b/packages/cli/src/__mocks__/utils.ts
@@ -9,3 +9,4 @@ export const printUnusedWarnings = jest.fn();
 export const printLintTotals = jest.fn();
 export const getOutputFileName = jest.fn(() => ({ outputFile: 'test.yaml', ext: 'yaml' }));
 export const handleError = jest.fn();
+export const exitWithError = jest.fn();
diff --git a/packages/cli/src/commands/join.ts b/packages/cli/src/commands/join.ts
index dcb7eb6b..05115398 100644
--- a/packages/cli/src/commands/join.ts
+++ b/packages/cli/src/commands/join.ts
@@ -1,5 +1,6 @@
 import * as path from 'path';
 import { red, blue, yellow, green } from 'colorette';
+import { outdent } from 'outdent';
 import { performance } from 'perf_hooks';
 const isEqual = require('lodash.isequal');
 import {
@@ -29,29 +30,54 @@ import {
 import { isObject, isString } from '../js-utils';
 
 const COMPONENTS = 'components';
+const Tags = 'tags';
+const xTagGroups = 'x-tagGroups';
 let potentialConflictsTotal = 0;
 
 type JoinDocumentContext = {
-  entrypoint: string,
-  entrypointFilename: string,
-  tags: Oas3Tag[],
-  potentialConflicts: any,
-  tagsPrefix: string,
-  componentsPrefix: string | undefined
-}
-
-export async function handleJoin (argv: {
-  entrypoints: string[],
-  lint?: boolean,
-  'prefix-tags-with-info-prop'?: string,
-  'prefix-tags-with-filename'?: boolean,
-  'prefix-components-with-info-prop'?: string
-},
-packageVersion: string
-) {
+  entrypoint: string;
+  entrypointFilename: string;
+  tags: Oas3Tag[];
+  potentialConflicts: any;
+  tagsPrefix: string;
+  componentsPrefix: string | undefined;
+};
+
+type JoinArgv = {
+  entrypoints: string[];
+  lint?: boolean;
+  'prefix-tags-with-info-prop'?: string;
+  'prefix-tags-with-filename'?: boolean;
+  'prefix-components-with-info-prop'?: string;
+  'without-x-tag-groups'?: boolean;
+};
+
+
+export async function handleJoin(argv: JoinArgv, packageVersion: string) {
   const startedAt = performance.now();
-  if (argv.entrypoints.length < 2) { return exitWithError(`At least 2 entrypoints should be provided. \n\n`); }
+  if (argv.entrypoints.length < 2) {
+    return exitWithError(`At least 2 entrypoints should be provided. \n\n`);
+  }
+
+  const {
+    'prefix-components-with-info-prop': prefixComponentsWithInfoProp,
+    'prefix-tags-with-filename': prefixTagsWithFilename,
+    'prefix-tags-with-info-prop': prefixTagsWithInfoProp,
+    'without-x-tag-groups': withoutXTagGroups,
+  } = argv;
 
+  const usedTagsOptions = [
+    prefixTagsWithFilename && 'prefix-tags-with-filename',
+    prefixTagsWithInfoProp && 'prefix-tags-with-info-prop',
+    withoutXTagGroups && 'without-x-tag-groups',
+  ].filter(Boolean);
+
+  if (usedTagsOptions.length > 1) {
+    return exitWithError(
+      `You use ${yellow(usedTagsOptions.join(', '))} together.\nPlease choose only one! \n\n`,
+    );
+  }
+ 
   const config: Config = await loadConfig();
   const entrypoints = await getFallbackEntryPointsOrExit(argv.entrypoints, config);
   const externalRefResolver = new BaseResolver(config.resolve);
@@ -71,7 +97,7 @@ packageVersion: string
     }))
   );
 
-  for (const { problems, bundle: document } of (bundleResults as any)) {
+  for (const { problems, bundle: document } of bundleResults as any) {
     const fileTotals = getTotals(problems);
     if (fileTotals.errors) {
       formatProblems(problems, {
@@ -104,18 +130,9 @@ packageVersion: string
     tags: {},
     paths: {},
     components: {},
-    xWebhooks: {}
+    xWebhooks: {},
   };
 
-  const prefixComponentsWithInfoProp = argv['prefix-components-with-info-prop'];
-  const prefixTagsWithFilename = argv['prefix-tags-with-filename'];
-  const prefixTagsWithInfoProp = argv['prefix-tags-with-info-prop'];
-  if (prefixTagsWithFilename && prefixTagsWithInfoProp) {
-    return exitWithError(
-      `You used ${yellow('prefix-tags-with-filename')} and ${yellow('prefix-tags-with-info-prop')} that do not go together.\nPlease choose only one! \n\n`
-    );
-  }
-
   addInfoSectionAndSpecVersion(documents, prefixComponentsWithInfoProp);
 
   for (const document of documents) {
@@ -141,10 +158,15 @@ packageVersion: string
     if (componentsPrefix) { replace$Refs(openapi, componentsPrefix); }
   }
 
-  iteratePotentialConflicts(potentialConflicts);
+  iteratePotentialConflicts(potentialConflicts, withoutXTagGroups);
   const specFilename = 'openapi.yaml';
   const noRefs = true;
-  if (!potentialConflictsTotal) { writeYaml(joinedDef, specFilename, noRefs); }
+
+  if (potentialConflictsTotal) {
+    return exitWithError(`Please fix conflicts before running ${yellow('join')}.`);
+  } 
+
+  writeYaml(joinedDef, specFilename, noRefs);
   printExecutionTime('join', startedAt, specFilename);
 
   function populateTags({
@@ -153,42 +175,87 @@ packageVersion: string
     tags,
     potentialConflicts,
     tagsPrefix,
-    componentsPrefix
+    componentsPrefix,
   }: JoinDocumentContext) {
-    const xTagGroups = 'x-tagGroups';
-    const Tags = 'tags';
-    if (!joinedDef.hasOwnProperty(Tags)) { joinedDef[Tags] = []; }
-    if (!joinedDef.hasOwnProperty(xTagGroups)) { joinedDef[xTagGroups] = []; }
-    if (!potentialConflicts.tags.hasOwnProperty('all')) { potentialConflicts.tags['all'] = {}; }
-    if (!joinedDef[xTagGroups].some((g: any) => g.name === entrypointFilename)) {
-      joinedDef[xTagGroups].push({ name: entrypointFilename, tags: [] });
+    if (!joinedDef.hasOwnProperty(Tags)) {
+      joinedDef[Tags] = [];
+    }
+    if (!potentialConflicts.tags.hasOwnProperty('all')) {
+      potentialConflicts.tags['all'] = {};
+    }
+    if (withoutXTagGroups && !potentialConflicts.tags.hasOwnProperty('description')) {
+      potentialConflicts.tags['description'] = {};
     }
-    const indexGroup = joinedDef[xTagGroups].findIndex((item: any) => item.name === entrypointFilename);
-    if (!joinedDef[xTagGroups][indexGroup].hasOwnProperty(Tags)) { joinedDef[xTagGroups][indexGroup][Tags] = []; }
     for (const tag of tags) {
       const entrypointTagName = addPrefix(tag.name, tagsPrefix);
       if (tag.description) {
         tag.description = addComponentsPrefix(tag.description, componentsPrefix!);
       }
-      if (!joinedDef.tags.find((t: any) => t.name === entrypointTagName)) {
+
+      const tagDuplicate = joinedDef.tags.find((t: Oas3Tag) => t.name === entrypointTagName);
+
+      if (tagDuplicate && withoutXTagGroups) {
+        // If tag already exist and `without-x-tag-groups` option;
+        // check if description are different for potential conflicts warning;
+        const isTagDescriptionNotEqual =
+          tag.hasOwnProperty('description') && tagDuplicate.description !== tag.description;
+
+        potentialConflicts.tags.description[entrypointTagName].push(
+          ...(isTagDescriptionNotEqual ? [entrypoint] : []),
+        );
+      } else {
+        // Instead add tag to joinedDef;
         tag['x-displayName'] = tag['x-displayName'] || tag.name;
         tag.name = entrypointTagName;
         joinedDef.tags.push(tag);
+
+        if (withoutXTagGroups) {
+          potentialConflicts.tags.description[entrypointTagName] = [entrypoint];
+        }
       }
-      if (!joinedDef[xTagGroups][indexGroup][Tags].find((t: any) => t === entrypointTagName)) {
-        joinedDef[xTagGroups][indexGroup][Tags].push(entrypointTagName);
+
+      if (!withoutXTagGroups) {
+        createXTagGroups(entrypointFilename);
+        populateXTagGroups(entrypointTagName, getIndexGroup(entrypointFilename));
       }
 
-      const doesEntrypointExist = !potentialConflicts.tags.all[entrypointTagName] || (
-        potentialConflicts.tags.all[entrypointTagName] &&
-        !potentialConflicts.tags.all[entrypointTagName].includes(entrypoint)
-      )
+      const doesEntrypointExist =
+        !potentialConflicts.tags.all[entrypointTagName] ||
+        (potentialConflicts.tags.all[entrypointTagName] &&
+          !potentialConflicts.tags.all[entrypointTagName].includes(entrypoint));
       potentialConflicts.tags.all[entrypointTagName] = [
-        ...(potentialConflicts.tags.all[entrypointTagName] || []), ...(doesEntrypointExist ? [entrypoint] : [])
+        ...(potentialConflicts.tags.all[entrypointTagName] || []),
+        ...(!withoutXTagGroups && doesEntrypointExist ? [entrypoint] : []),
       ];
     }
   }
 
+  function getIndexGroup(entrypointFilename: string): number {
+    return joinedDef[xTagGroups].findIndex((item: any) => item.name === entrypointFilename);
+  }
+
+  function createXTagGroups(entrypointFilename: string) {
+    if (!joinedDef.hasOwnProperty(xTagGroups)) {
+      joinedDef[xTagGroups] = [];
+    }
+
+    if (!joinedDef[xTagGroups].some((g: any) => g.name === entrypointFilename)) {
+      joinedDef[xTagGroups].push({ name: entrypointFilename, tags: [] });
+    }
+
+    const indexGroup = getIndexGroup(entrypointFilename);
+
+    if (!joinedDef[xTagGroups][indexGroup].hasOwnProperty(Tags)) {
+      joinedDef[xTagGroups][indexGroup][Tags] = [];
+    }
+  }
+
+  function populateXTagGroups(entrypointTagName: string, indexGroup: number) {
+    if (!joinedDef[xTagGroups][indexGroup][Tags].find((t: Oas3Tag) => t.name === entrypointTagName)) {
+      joinedDef[xTagGroups][indexGroup][Tags].push(entrypointTagName);
+    }
+  }
+
   function collectServers(openapi: Oas3Definition) {
     const { servers } = openapi;
     if (servers) {
@@ -210,15 +277,17 @@ packageVersion: string
   ) {
     const { info } = openapi;
     if (info?.description) {
-      const xTagGroups = 'x-tagGroups';
-      const groupIndex = joinedDef[xTagGroups] ? joinedDef[xTagGroups].findIndex((item: any) => item.name === entrypointFilename) : -1;
+      const groupIndex = joinedDef[xTagGroups] ? getIndexGroup(entrypointFilename) : -1;
       if (
         joinedDef.hasOwnProperty(xTagGroups) &&
         groupIndex !== -1 &&
         joinedDef[xTagGroups][groupIndex]['tags'] &&
         joinedDef[xTagGroups][groupIndex]['tags'].length
       ) {
-        joinedDef[xTagGroups][groupIndex]['description'] = addComponentsPrefix(info.description, componentsPrefix!);
+        joinedDef[xTagGroups][groupIndex]['description'] = addComponentsPrefix(
+          info.description,
+          componentsPrefix!,
+        );
       }
     }
   }
@@ -369,7 +438,7 @@ function validateComponentsDifference(files: any) {
   return isDiffer;
 }
 
-function iteratePotentialConflicts(potentialConflicts: any) {
+function iteratePotentialConflicts(potentialConflicts: any, withoutXTagGroups?: boolean) {
   for (const group of Object.keys(potentialConflicts)) {
     for (const [key, value] of Object.entries(potentialConflicts[group])) {
       const conflicts = filterConflicts(value as object);
@@ -383,22 +452,45 @@ function iteratePotentialConflicts(potentialConflicts: any) {
             }
           }
         } else {
-          showConflicts(green(group) +' => '+ key, conflicts);
-          potentialConflictsTotal += conflicts.length;
+          if (withoutXTagGroups && group === 'tags') {
+            duplicateTagDescriptionWarning(conflicts);
+          } else {
+            potentialConflictsTotal += conflicts.length;
+            showConflicts(green(group) + ' => ' + key, conflicts);
+          }
+        }
+
+        if (group === 'tags' && !withoutXTagGroups) {
+          prefixTagSuggestion(conflicts.length);
         }
-        prefixTagSuggestion(group, conflicts.length);
       }
     }
   }
 }
 
-function prefixTagSuggestion(group: string, conflictsLength: number) {
-  if (group === 'tags') {
-    process.stderr.write(green(`
-    ${conflictsLength} conflict(s) on tags.
-    Suggestion: please use ${blue('prefix-tags-with-filename')} or ${blue('prefix-tags-with-info-prop')} to prevent naming conflicts. \n\n`
-    ));
-  }
+function duplicateTagDescriptionWarning(conflicts: [string, any][]) {
+  const tagsKeys = conflicts.map(([tagName]) => `\`${tagName}\``);
+  const joinString = yellow(', ');
+  process.stderr.write(
+    yellow(
+      `\nwarning: ${tagsKeys.length} conflict(s) on the ${red(
+        tagsKeys.join(joinString),
+      )} tags description.\n`,
+    ),
+  );
+}
+
+function prefixTagSuggestion(conflictsLength: number) {
+  process.stderr.write(
+    green(
+      outdent`\n
+      ${conflictsLength} conflict(s) on tags.
+      Suggestion: please use ${blue('prefix-tags-with-filename')}, ${blue(
+        'prefix-tags-with-info-prop',
+      )} or ${blue('without-x-tag-groups')} to prevent naming conflicts.\n\n
+    `,
+    ),
+  );
 }
 
 function showConflicts(key: string, conflicts: any) {
@@ -484,5 +576,5 @@ function replace$Refs(obj: any, componentsPrefix: string) {
         }
       }
     }
-  })
+  });
 }
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index d8b79114..8af5f693 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -83,6 +83,10 @@ yargs
             requiresArg: true,
             type: 'string',
           },
+          'without-x-tag-groups': {
+            description: 'Skip automated x-tagGroups creation',
+            type: 'boolean'
+          }
         }),
     (argv) => {
       handleJoin(argv, version);


Test patch:
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index b23764b7..c3b41619 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -113,41 +113,47 @@ describe('E2E', () => {
     });
   });
 
+
   describe('join', () => {
-    const folderPath = join(__dirname, 'join');
-    const contents = readdirSync(folderPath);
+    const args = [
+      '--transpile-only',
+      '../../../packages/cli/src/index.ts',
+      'join',
+      'foo.yaml',
+      'bar.yaml',
+    ];
 
-    for (const file of contents) {
-      const testPath = join(folderPath, file);
+    describe('without options', () => {
+      const testDirNames = [
+        'fails-if-no-info-section',
+        'fails-if-tags-duplication',
+        'reference-in-description',
+        'two-files-with-no-errors',
+        'fails-if-component-conflicts',
+      ];
 
-      if (statSync(testPath).isFile()) {
-        continue;
-      }
+      test.each(testDirNames)('test: %s', (dir) => {
+        const testPath = join(__dirname, `join/${dir}`);
+        const result = getCommandOutput(args, testPath);
+        (<any>expect(result)).toMatchSpecificSnapshot(join(testPath, 'snapshot.js'));
+      });
+    });
 
-      const args = [
-        '--transpile-only',
-        '../../../packages/cli/src/index.ts',
-        'join',
-        'foo.yaml',
-        'bar.yaml'
+    describe('with options', () => {
+      const options: { name: string; value: string | boolean }[] = [
+        { name: 'prefix-tags-with-info-prop', value: 'title' },
+        { name: 'prefix-tags-with-filename', value: true },
+        { name: 'without-x-tag-groups', value: true },
+        { name: 'prefix-components-with-info-prop', value: 'title' },
       ];
 
-      it(file, () => {
-        const r = spawnSync('ts-node', args, {
-          cwd: testPath,
-          env: {
-            ...process.env,
-            NODE_ENV: 'test',
-            NO_COLOR: 'TRUE',
-          },
-        });
-
-        const out = r.stdout.toString('utf-8');
-        const err = r.stderr.toString('utf-8');
-        const result = `${out}\n${err}`;
+      test.each(options)('test with option: %s', (option) => {
+        const testPath = join(__dirname, `join/${option.name}`);
+        const argsWithOptions = [...args, ...[`--${option.name}=${option.value}`]];
+        const result = getCommandOutput(argsWithOptions, testPath);
         (<any>expect(result)).toMatchSpecificSnapshot(join(testPath, 'snapshot.js'));
       });
-    }
+    });
   });
 
   describe('bundle', () => {
diff --git a/__tests__/join/fails-if-component-conflicts/bar.yaml b/__tests__/join/fails-if-component-conflicts/bar.yaml
new file mode 100644
index 00000000..48e7d7d7
--- /dev/null
+++ b/__tests__/join/fails-if-component-conflicts/bar.yaml
@@ -0,0 +1,27 @@
+openapi: 3.0.0
+tags:
+  - name: Bar
+    description: Bar Discription
+info:
+  version: 1.0.0
+  title: Example OpenAPI 3 definition.
+  description: Information about API
+  license:
+    name: MIT
+    url: 'https://opensource.org/licenses/MIT'
+servers:
+  - url: 'https://redocly.com/v1'
+paths:
+  '/pets/{petId}':
+    post:
+      summary: summary example
+      operationId: exampleBar
+      responses:
+        '201':
+          description: example description
+
+components:
+  schemas:
+    some-property:
+      description: bar description
+      type: string
diff --git a/__tests__/join/fails-if-component-conflicts/foo.yaml b/__tests__/join/fails-if-component-conflicts/foo.yaml
new file mode 100644
index 00000000..e10089ce
--- /dev/null
+++ b/__tests__/join/fails-if-component-conflicts/foo.yaml
@@ -0,0 +1,35 @@
+openapi: 3.0.0
+tags:
+  - name: Foo
+    description: Foo Discription
+info:
+  version: 1.0.0
+  title: Example OpenAPI 3 definition foo.
+  description: Information about API
+  license:
+    name: MIT
+    url: 'https://opensource.org/licenses/MIT'
+servers:
+  - url: 'https://redocly.com/v1'
+paths:
+  /pets:
+    get:
+      summary: Test summary
+      operationId: exampleFoo
+      parameters:
+        - name: limit
+          in: query
+          description: How many items to return at one time (max 100)
+          required: false
+          schema:
+            type: integer
+            format: int
+      responses:
+        '200':
+          description: example description
+
+components:
+  schemas:
+    some-property:
+      description: foo description
+      type: boolean
diff --git a/__tests__/join/fails-if-component-conflicts/snapshot.js b/__tests__/join/fails-if-component-conflicts/snapshot.js
new file mode 100644
index 00000000..1f256145
--- /dev/null
+++ b/__tests__/join/fails-if-component-conflicts/snapshot.js
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`E2E join without options test: fails-if-component-conflicts 1`] = `
+
+Conflict on components => schemas : some-property in files: foo.yaml,bar.yaml 
+Please fix conflicts before running join.
+
+
+`;
diff --git a/__tests__/join/fails-if-no-info-section/snapshot.js b/__tests__/join/fails-if-no-info-section/snapshot.js
index 2395ab6d..3cfb3e36 100644
--- a/__tests__/join/fails-if-no-info-section/snapshot.js
+++ b/__tests__/join/fails-if-no-info-section/snapshot.js
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`E2E join fails-if-no-info-section 1`] = `
+exports[`E2E join without options test: fails-if-no-info-section 1`] = `
 
 Info section is not found in specification. 
 
diff --git a/__tests__/join/fails-if-tags-duplication/bar.yaml b/__tests__/join/fails-if-tags-duplication/bar.yaml
new file mode 100644
index 00000000..abfd5554
--- /dev/null
+++ b/__tests__/join/fails-if-tags-duplication/bar.yaml
@@ -0,0 +1,21 @@
+openapi: 3.0.0
+tags:
+  - name: Pet
+    description: Pet Discription
+info:
+  version: 1.0.0
+  title: Example OpenAPI 3 definition.
+  description: Information about API
+  license:
+    name: MIT
+    url: 'https://opensource.org/licenses/MIT'
+servers:
+  - url: 'https://redocly.com/v1'
+paths:
+  '/pets/{petId}':
+    post:
+      summary: summary example
+      operationId: exampleBar
+      responses:
+        '201':
+          description: example description
diff --git a/__tests__/join/fails-if-tags-duplication/foo.yaml b/__tests__/join/fails-if-tags-duplication/foo.yaml
new file mode 100644
index 00000000..f27e7ca2
--- /dev/null
+++ b/__tests__/join/fails-if-tags-duplication/foo.yaml
@@ -0,0 +1,29 @@
+openapi: 3.0.0
+tags:
+  - name: Pet
+    description: Pet Discription
+info:
+  version: 1.0.0
+  title: Example OpenAPI 3 definition.
+  description: Information about API
+  license:
+    name: MIT
+    url: 'https://opensource.org/licenses/MIT'
+servers:
+  - url: 'https://redocly.com/v1'
+paths:
+  /pets:
+    get:
+      summary: Test summary
+      operationId: exampleFoo
+      parameters:
+        - name: limit
+          in: query
+          description: How many items to return at one time (max 100)
+          required: false
+          schema:
+            type: integer
+            format: int
+      responses:
+        '200':
+          description: example description
diff --git a/__tests__/join/fails-if-tags-duplication/snapshot.js b/__tests__/join/fails-if-tags-duplication/snapshot.js
new file mode 100644
index 00000000..934ad316
--- /dev/null
+++ b/__tests__/join/fails-if-tags-duplication/snapshot.js
@@ -0,0 +1,13 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`E2E join without options test: fails-if-tags-duplication 1`] = `
+
+Conflict on tags => all : Pet in files: foo.yaml,bar.yaml 
+
+1 conflict(s) on tags.
+Suggestion: please use prefix-tags-with-filename, prefix-tags-with-info-prop or without-x-tag-groups to prevent naming conflicts.
+
+Please fix conflicts before running join.
+
+
+`;
diff --git a/__tests__/join/prefix-components-with-info-prop/bar.yaml b/__tests__/join/prefix-components-with-info-prop/bar.yaml
new file mode 100644
index 00000000..48e7d7d7
--- /dev/null
+++ b/__tests__/join/prefix-components-with-info-prop/bar.yaml
@@ -0,0 +1,27 @@
+openapi: 3.0.0
+tags:
+  - name: Bar
+    description: Bar Discription
+info:
+  version: 1.0.0
+  title: Example OpenAPI 3 definition.
+  description: Information about API
+  license:
+    name: MIT
+    url: 'https://opensource.org/licenses/MIT'
+servers:
+  - url: 'https://redocly.com/v1'
+paths:
+  '/pets/{petId}':
+    post:
+      summary: summary example
+      operationId: exampleBar
+      responses:
+        '201':
+          description: example description
+
+components:
+  schemas:
+    some-property:
+      description: bar description
+      type: string
diff --git a/__tests__/join/prefix-components-with-info-prop/foo.yaml b/__tests__/join/prefix-components-with-info-prop/foo.yaml
new file mode 100644
index 00000000..698129c6
--- /dev/null
+++ b/__tests__/join/prefix-components-with-info-prop/foo.yaml
@@ -0,0 +1,35 @@
+openapi: 3.0.0
+tags:
+  - name: Foo
+    description: Foo Discription
+info:
+  version: 1.0.0
+  title: Example OpenAPI 3 definition foo.
+  description: Information about API
+  license:
+    name: MIT
+    url: 'https://opensource.org/licenses/MIT'
+servers:
+  - url: 'https://redocly.com/v1'
+paths:
+  /pets:
+    get:
+      summary: Test summary
+      operationId: exampleFoo
+      parameters:
+        - name: limit
+          in: query
+          description: How many items to return at one time (max 100)
+          required: false
+          schema:
+            type: integer
+            format: int
+      responses:
+        '200':
+          description: example description
+
+components:
+  schemas:
+    some-property:
+      description: foo description
+      type: string
diff --git a/__tests__/join/prefix-components-with-info-prop/snapshot.js b/__tests__/join/prefix-components-with-info-prop/snapshot.js
new file mode 100644
index 00000000..b598dd7f
--- /dev/null
+++ b/__tests__/join/prefix-components-with-info-prop/snapshot.js
@@ -0,0 +1,76 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`E2E join with options test with option: { name: 'prefix-components-with-info-prop', value: 'title' } 1`] = `
+
+openapi: 3.0.0
+info:
+  version: 1.0.0
+  title: Example OpenAPI 3 definition foo.
+  description: Information about API
+  license:
+    name: MIT
+    url: https://opensource.org/licenses/MIT
+tags:
+  - name: Foo
+    description: Foo Discription
+    x-displayName: Foo
+  - name: foo_other
+    x-displayName: other
+  - name: Bar
+    description: Bar Discription
+    x-displayName: Bar
+  - name: bar_other
+    x-displayName: other
+x-tagGroups:
+  - name: foo
+    tags:
+      - Foo
+      - foo_other
+    description: Information about API
+  - name: bar
+    tags:
+      - Bar
+      - bar_other
+    description: Information about API
+servers:
+  - url: https://redocly.com/v1
+paths:
+  /pets:
+    get:
+      summary: Test summary
+      operationId: exampleFoo
+      parameters:
+        - name: limit
+          in: query
+          description: How many items to return at one time (max 100)
+          required: false
+          schema:
+            type: integer
+            format: int
+      responses:
+        '200':
+          description: example description
+      tags:
+        - foo_other
+  /pets/{petId}:
+    post:
+      summary: summary example
+      operationId: exampleBar
+      responses:
+        '201':
+          description: example description
+      tags:
+        - bar_other
+components:
+  schemas:
+    Example OpenAPI 3 definition foo._some-property:
+      description: foo description
+      type: string
+    Example OpenAPI 3 definition._some-property:
+      description: bar description
+      type: string
+
+openapi.yaml: join processed in <test>ms
+
+
+`;
diff --git a/__tests__/join/prefix-tags-with-filename/bar.yaml b/__tests__/join/prefix-tags-with-filename/bar.yaml
new file mode 100644
index 00000000..8677cf82
--- /dev/null
+++ b/__tests__/join/prefix-tags-with-filename/bar.yaml
@@ -0,0 +1,21 @@
+openapi: 3.0.0
+tags: 
+  - name: Pet
+    description: Pet Discription  
+info:
+  version: 1.0.0
+  title: Example OpenAPI 3 definition.
+  description: Information about API
+  license:
+    name: MIT
+    url: 'https://opensource.org/licenses/MIT'
+servers:
+  - url: 'https://redocly.com/v1'
+paths:
+  '/pets/{petId}':
+    post:
+      summary: summary example
+      operationId: exampleBar
+      responses:
+        '201':
+          description: example description
\ No newline at end of file
diff --git a/__tests__/join/prefix-tags-with-filename/foo.yaml b/__tests__/join/prefix-tags-with-filename/foo.yaml
new file mode 100644
index 00000000..b299cd42
--- /dev/null
+++ b/__tests__/join/prefix-tags-with-filename/foo.yaml
@@ -0,0 +1,29 @@
+openapi: 3.0.0
+tags: 
+  - name: Pet
+    description: Pet Discription 
+info:
+  version: 1.0.0
+  title: Example OpenAPI 3 definition.
+  description: Information about API
+  license:
+    name: MIT
+    url: 'https://opensource.org/licenses/MIT'
+servers:
+  - url: 'https://redocly.com/v1'
+paths:
+  /pets:
+    get:
+      summary: Test summary
+      operationId: exampleFoo
+      parameters:
+        - name: limit
+          in: query
+          description: How many items to return at one time (max 100)
+          required: false
+          schema:
+            type: integer
+            format: int
+      responses:
+        '200':
+          description: example description
\ No newline at end of file
diff --git a/__tests__/join/prefix-tags-with-filename/snapshot.js b/__tests__/join/prefix-tags-with-filename/snapshot.js
new file mode 100644
index 00000000..b4020193
--- /dev/null
+++ b/__tests__/join/prefix-tags-with-filename/snapshot.js
@@ -0,0 +1,69 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`E2E join with options test with option: { name: 'prefix-tags-with-filename', value: true } 1`] = `
+
+openapi: 3.0.0
+info:
+  version: 1.0.0
+  title: Example OpenAPI 3 definition.
+  description: Information about API
+  license:
+    name: MIT
+    url: https://opensource.org/licenses/MIT
+tags:
+  - name: foo_Pet
+    description: Pet Discription
+    x-displayName: Pet
+  - name: foo_other
+    x-displayName: other
+  - name: bar_Pet
+    description: Pet Discription
+    x-displayName: Pet
+  - name: bar_other
+    x-displayName: other
+x-tagGroups:
+  - name: foo
+    tags:
+      - foo_Pet
+      - foo_other
+    description: Information about API
+  - name: bar
+    tags:
+      - bar_Pet
+      - bar_other
+    description: Information about API
+servers:
+  - url: https://redocly.com/v1
+paths:
+  /pets:
+    get:
+      summary: Test summary
+      operationId: exampleFoo
+      parameters:
+        - name: limit
+          in: query
+          description: How many items to return at one time (max 100)
+          required: false
+          schema:
+            type: integer
+            format: int
+      responses:
+        '200':
+          description: example description
+      tags:
+        - foo_other
+  /pets/{petId}:
+    post:
+      summary: summary example
+      operationId: exampleBar
+      responses:
+        '201':
+          description: example description
+      tags:
+        - bar_other
+components: {}
+
+openapi.yaml: join processed in <test>ms
+
+
+`;
diff --git a/__tests__/join/prefix-tags-with-info-prop/bar.yaml b/__tests__/join/prefix-tags-with-info-prop/bar.yaml
new file mode 100644
index 00000000..abfd5554
--- /dev/null
+++ b/__tests__/join/prefix-tags-with-info-prop/bar.yaml
@@ -0,0 +1,21 @@
+openapi: 3.0.0
+tags:
+  - name: Pet
+    description: Pet Discription
+info:
+  version: 1.0.0
+  title: Example OpenAPI 3 definition.
+  description: Information about API
+  license:
+    name: MIT
+    url: 'https://opensource.org/licenses/MIT'
+servers:
+  - url: 'https://redocly.com/v1'
+paths:
+  '/pets/{petId}':
+    post:
+      summary: summary example
+      operationId: exampleBar
+      responses:
+        '201':
+          description: example description
diff --git a/__tests__/join/prefix-tags-with-info-prop/foo.yaml b/__tests__/join/prefix-tags-with-info-prop/foo.yaml
new file mode 100644
index 00000000..1e6970af
--- /dev/null
+++ b/__tests__/join/prefix-tags-with-info-prop/foo.yaml
@@ -0,0 +1,29 @@
+openapi: 3.0.0
+tags: 
+  - name: Pet
+    description: Pet Discription 
+info:
+  version: 1.0.0
+  title: Example OpenAPI 3 definition with foo title.
+  description: Information about API
+  license:
+    name: MIT
+    url: 'https://opensource.org/licenses/MIT'
+servers:
+  - url: 'https://redocly.com/v1'
+paths:
+  /pets:
+    get:
+      summary: Test summary
+      operationId: exampleFoo
+      parameters:
+        - name: limit
+          in: query
+          description: How many items to return at one time (max 100)
+          required: false
+          schema:
+            type: integer
+            format: int
+      responses:
+        '200':
+          description: example description
\ No newline at end of file
diff --git a/__tests__/join/prefix-tags-with-info-prop/snapshot.js b/__tests__/join/prefix-tags-with-info-prop/snapshot.js
new file mode 100644
index 00000000..f65c0404
--- /dev/null
+++ b/__tests__/join/prefix-tags-with-info-prop/snapshot.js
@@ -0,0 +1,69 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`E2E join with options test with option: { name: 'prefix-tags-with-info-prop', value: 'title' } 1`] = `
+
+openapi: 3.0.0
+info:
+  version: 1.0.0
+  title: Example OpenAPI 3 definition with foo title.
+  description: Information about API
+  license:
+    name: MIT
+    url: https://opensource.org/licenses/MIT
+tags:
+  - name: Example OpenAPI 3 definition with foo title._Pet
+    description: Pet Discription
+    x-displayName: Pet
+  - name: Example OpenAPI 3 definition with foo title._other
+    x-displayName: other
+  - name: Example OpenAPI 3 definition._Pet
+    description: Pet Discription
+    x-displayName: Pet
+  - name: Example OpenAPI 3 definition._other
+    x-displayName: other
+x-tagGroups:
+  - name: foo
+    tags:
+      - Example OpenAPI 3 definition with foo title._Pet
+      - Example OpenAPI 3 definition with foo title._other
+    description: Information about API
+  - name: bar
+    tags:
+      - Example OpenAPI 3 definition._Pet
+      - Example OpenAPI 3 definition._other
+    description: Information about API
+servers:
+  - url: https://redocly.com/v1
+paths:
+  /pets:
+    get:
+      summary: Test summary
+      operationId: exampleFoo
+      parameters:
+        - name: limit
+          in: query
+          description: How many items to return at one time (max 100)
+          required: false
+          schema:
+            type: integer
+            format: int
+      responses:
+        '200':
+          description: example description
+      tags:
+        - Example OpenAPI 3 definition with foo title._other
+  /pets/{petId}:
+    post:
+      summary: summary example
+      operationId: exampleBar
+      responses:
+        '201':
+          description: example description
+      tags:
+        - Example OpenAPI 3 definition._other
+components: {}
+
+openapi.yaml: join processed in <test>ms
+
+
+`;
diff --git a/__tests__/join/reference-in-description/bar.yaml b/__tests__/join/reference-in-description/bar.yaml
index 1edddb60..88ce1deb 100644
--- a/__tests__/join/reference-in-description/bar.yaml
+++ b/__tests__/join/reference-in-description/bar.yaml
@@ -15,4 +15,4 @@ paths:
       operationId: exampleBar
       responses:
         '201':
-          description: example description
\ No newline at end of file
+          description: example description
diff --git a/__tests__/join/reference-in-description/foo.yaml b/__tests__/join/reference-in-description/foo.yaml
index 093f015c..0505cd55 100644
--- a/__tests__/join/reference-in-description/foo.yaml
+++ b/__tests__/join/reference-in-description/foo.yaml
@@ -24,4 +24,4 @@ paths:
             format: int
       responses:
         '200':
-          description: example description
\ No newline at end of file
+          description: example description
diff --git a/__tests__/join/reference-in-description/snapshot.js b/__tests__/join/reference-in-description/snapshot.js
index 2637d7d8..911d0dd1 100644
--- a/__tests__/join/reference-in-description/snapshot.js
+++ b/__tests__/join/reference-in-description/snapshot.js
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`E2E join reference-in-description 1`] = `
+exports[`E2E join without options test: reference-in-description 1`] = `
 
 openapi: 3.0.0
 info:
diff --git a/__tests__/join/two-files-with-no-errors/bar.yaml b/__tests__/join/two-files-with-no-errors/bar.yaml
index 1edddb60..88ce1deb 100644
--- a/__tests__/join/two-files-with-no-errors/bar.yaml
+++ b/__tests__/join/two-files-with-no-errors/bar.yaml
@@ -15,4 +15,4 @@ paths:
       operationId: exampleBar
       responses:
         '201':
-          description: example description
\ No newline at end of file
+          description: example description
diff --git a/__tests__/join/two-files-with-no-errors/foo.yaml b/__tests__/join/two-files-with-no-errors/foo.yaml
index fdf02079..c5712560 100644
--- a/__tests__/join/two-files-with-no-errors/foo.yaml
+++ b/__tests__/join/two-files-with-no-errors/foo.yaml
@@ -23,4 +23,4 @@ paths:
             format: int
       responses:
         '200':
-          description: example description
\ No newline at end of file
+          description: example description
diff --git a/__tests__/join/two-files-with-no-errors/snapshot.js b/__tests__/join/two-files-with-no-errors/snapshot.js
index 22d24f2a..994196b1 100644
--- a/__tests__/join/two-files-with-no-errors/snapshot.js
+++ b/__tests__/join/two-files-with-no-errors/snapshot.js
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`E2E join two-files-with-no-errors 1`] = `
+exports[`E2E join without options test: two-files-with-no-errors 1`] = `
 
 openapi: 3.0.0
 info:
diff --git a/__tests__/join/without-x-tag-groups/bar.yaml b/__tests__/join/without-x-tag-groups/bar.yaml
new file mode 100644
index 00000000..24cff6a3
--- /dev/null
+++ b/__tests__/join/without-x-tag-groups/bar.yaml
@@ -0,0 +1,21 @@
+openapi: 3.0.0
+tags: 
+  - name: Pet
+    description: Another description
+info:
+  version: 1.0.0
+  title: Example OpenAPI 3 definition.
+  description: Information about API
+  license:
+    name: MIT
+    url: 'https://opensource.org/licenses/MIT'
+servers:
+  - url: 'https://redocly.com/v1'
+paths:
+  '/pets/{petId}':
+    post:
+      summary: summary example
+      operationId: exampleBar
+      responses:
+        '201':
+          description: example description
\ No newline at end of file
diff --git a/__tests__/join/without-x-tag-groups/foo.yaml b/__tests__/join/without-x-tag-groups/foo.yaml
new file mode 100644
index 00000000..f27e7ca2
--- /dev/null
+++ b/__tests__/join/without-x-tag-groups/foo.yaml
@@ -0,0 +1,29 @@
+openapi: 3.0.0
+tags:
+  - name: Pet
+    description: Pet Discription
+info:
+  version: 1.0.0
+  title: Example OpenAPI 3 definition.
+  description: Information about API
+  license:
+    name: MIT
+    url: 'https://opensource.org/licenses/MIT'
+servers:
+  - url: 'https://redocly.com/v1'
+paths:
+  /pets:
+    get:
+      summary: Test summary
+      operationId: exampleFoo
+      parameters:
+        - name: limit
+          in: query
+          description: How many items to return at one time (max 100)
+          required: false
+          schema:
+            type: integer
+            format: int
+      responses:
+        '200':
+          description: example description
diff --git a/__tests__/join/without-x-tag-groups/snapshot.js b/__tests__/join/without-x-tag-groups/snapshot.js
new file mode 100644
index 00000000..33e29f40
--- /dev/null
+++ b/__tests__/join/without-x-tag-groups/snapshot.js
@@ -0,0 +1,57 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`E2E join with options test with option: { name: 'without-x-tag-groups', value: true } 1`] = `
+
+
+warning: 1 conflict(s) on the \`Pet\` tags description.
+openapi: 3.0.0
+info:
+  version: 1.0.0
+  title: Example OpenAPI 3 definition.
+  description: Information about API
+  license:
+    name: MIT
+    url: https://opensource.org/licenses/MIT
+tags:
+  - name: Pet
+    description: Pet Discription
+    x-displayName: Pet
+  - name: foo_other
+    x-displayName: other
+  - name: bar_other
+    x-displayName: other
+servers:
+  - url: https://redocly.com/v1
+paths:
+  /pets:
+    get:
+      summary: Test summary
+      operationId: exampleFoo
+      parameters:
+        - name: limit
+          in: query
+          description: How many items to return at one time (max 100)
+          required: false
+          schema:
+            type: integer
+            format: int
+      responses:
+        '200':
+          description: example description
+      tags:
+        - foo_other
+  /pets/{petId}:
+    post:
+      summary: summary example
+      operationId: exampleBar
+      responses:
+        '201':
+          description: example description
+      tags:
+        - bar_other
+components: {}
+
+openapi.yaml: join processed in <test>ms
+
+
+`;
diff --git a/packages/cli/src/__tests__/commands/join.test.ts b/packages/cli/src/__tests__/commands/join.test.ts
new file mode 100644
index 00000000..6bffc69b
--- /dev/null
+++ b/packages/cli/src/__tests__/commands/join.test.ts
@@ -0,0 +1,47 @@
+import { handleJoin } from '../../commands/join';
+import { exitWithError } from '../../utils';
+import { yellow } from 'colorette';
+
+jest.mock('../../utils');
+jest.mock('colorette');
+
+describe('handleJoin fails', () => {
+  const colloreteYellowMock = yellow as jest.Mock<any, any>;
+  colloreteYellowMock.mockImplementation((string: string) => string);
+
+  it('should call exitWithError because only one entrypoint', async () => {
+    await handleJoin({ entrypoints: ['first.yaml'] }, 'cli-version');
+    expect(exitWithError).toHaveBeenCalledWith(`At least 2 entrypoints should be provided. \n\n`);
+  });
+
+  it('should call exitWithError because passed all 3 options for tags', async () => {
+    await handleJoin(
+      {
+        entrypoints: ['first.yaml', 'second.yaml'],
+        'prefix-tags-with-info-prop': 'something',
+        'without-x-tag-groups': true,
+        'prefix-tags-with-filename': true,
+      },
+      'cli-version',
+    );
+
+    expect(exitWithError).toHaveBeenCalledWith(
+      `You use prefix-tags-with-filename, prefix-tags-with-info-prop, without-x-tag-groups together.\nPlease choose only one! \n\n`,
+    );
+  });
+
+  it('should call exitWithError because passed all 2 options for tags', async () => {
+    await handleJoin(
+      {
+        entrypoints: ['first.yaml', 'second.yaml'],
+        'without-x-tag-groups': true,
+        'prefix-tags-with-filename': true,
+      },
+      'cli-version',
+    );
+
+    expect(exitWithError).toHaveBeenCalledWith(
+      `You use prefix-tags-with-filename, without-x-tag-groups together.\nPlease choose only one! \n\n`,
+    );
+  });
+});