Repository: stoplightio/spectral (ts)

Issue:
Add rule to check uniqueness of `messageId`
**User story.**
As a user, I want to know if the messages defined in my AsyncAPI document have a unique `messageId`.

**Describe the solution you'd like**
The following AsyncAPI document is invalid and Spectral should throw errors due to non-uniqueness of `messageId`.

```yaml
asyncapi: '2.4.0'
...

channels:
  someChannel:
    publish:
      message:
        messageId: someId
        ...
    subscribe:
      message:
        messageId: someId
```

**Additional context**
Part of #2100 


Pull request:
feat(rulesets): check uniqueness of AsyncAPI messages
Fixes #2223 

**Checklist**

- [X] Tests added / updated
- [X] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [X] No

> If indicated yes above, please describe the breaking change(s).
>
> **Remove this quote before creating the PR.**

Part of #2100 

Add rule to check uniqueness `messageId` of AsyncAPI messages.

**Bad Example**

```yaml
channels:
  smartylighting.streetlights.1.0.action.{streetlightId}.turn.on:
    publish:
      message:
        messageId: turnMessage
  smartylighting.streetlights.1.0.action.{streetlightId}.turn.off:
    publish:
      message:
        messageId: turnMessage
```

**Good Example**

```yaml
channels:
  smartylighting.streetlights.1.0.action.{streetlightId}.turn.on:
    publish:
      message:
        messageId: turnOnMessage
  smartylighting.streetlights.1.0.action.{streetlightId}.turn.off:
    publish:
      message:
        messageId: turnOffMessage
```

---

Also fix rule for `operationId` uniqueness - check `operationId` in operation's traits. 

cc @smoya @jonaslagoni 

Patch:
diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md
index d2287478..26a4a33f 100644
--- a/docs/reference/asyncapi-rules.md
+++ b/docs/reference/asyncapi-rules.md
@@ -219,6 +219,40 @@ components:
 
 **Recommended:** Yes
 
+### asyncapi-message-messageId-uniqueness
+
+`messageId` must be unique across all the messages (except those one defined in the components).
+
+**Recommended:** Yes
+
+**Bad Example**
+
+```yaml
+channels:
+  smartylighting.streetlights.1.0.action.{streetlightId}.turn.on:
+    publish:
+      message:
+        messageId: turnMessage
+  smartylighting.streetlights.1.0.action.{streetlightId}.turn.off:
+    publish:
+      message:
+        messageId: turnMessage
+```
+
+**Good Example**
+
+```yaml
+channels:
+  smartylighting.streetlights.1.0.action.{streetlightId}.turn.on:
+    publish:
+      message:
+        messageId: turnOnMessage
+  smartylighting.streetlights.1.0.action.{streetlightId}.turn.off:
+    publish:
+      message:
+        messageId: turnOffMessage
+```
+
 ### asyncapi-operation-description
 
 Operation objects should have a description.
diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2MessageIdUniqueness.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2MessageIdUniqueness.ts
new file mode 100644
index 00000000..47b125e1
--- /dev/null
+++ b/packages/rulesets/src/asyncapi/functions/asyncApi2MessageIdUniqueness.ts
@@ -0,0 +1,111 @@
+import { createRulesetFunction } from '@stoplight/spectral-core';
+import { isPlainObject } from '@stoplight/json';
+
+import { getAllMessages } from './utils/getAllMessages';
+
+import type { IFunctionResult } from '@stoplight/spectral-core';
+import type { JsonPath } from '@stoplight/types';
+
+function retrieveMessageId(message: {
+  messageId?: string;
+  traits?: Array<{ messageId?: string }>;
+}): { messageId: string; path: JsonPath } | undefined {
+  if (Array.isArray(message.traits)) {
+    for (let i = message.traits.length - 1; i >= 0; i--) {
+      const trait = message.traits[i];
+      if (isPlainObject(trait) && typeof trait.messageId === 'string') {
+        return {
+          messageId: trait.messageId,
+          path: ['traits', i, 'messageId'],
+        };
+      }
+    }
+  }
+
+  if (typeof message.messageId === 'string') {
+    return {
+      messageId: message.messageId,
+      path: ['messageId'],
+    };
+  }
+
+  return undefined;
+}
+
+export default createRulesetFunction<
+  { channels: Record<string, { subscribe: Record<string, unknown>; publish: Record<string, unknown> }> },
+  null
+>(
+  {
+    input: {
+      type: 'object',
+      properties: {
+        channels: {
+          type: 'object',
+          properties: {
+            subscribe: {
+              type: 'object',
+              properties: {
+                message: {
+                  oneOf: [
+                    { type: 'object' },
+                    {
+                      type: 'object',
+                      properties: {
+                        oneOf: {
+                          type: 'array',
+                        },
+                      },
+                    },
+                  ],
+                },
+              },
+            },
+            publish: {
+              type: 'object',
+              properties: {
+                message: {
+                  oneOf: [
+                    { type: 'object' },
+                    {
+                      type: 'object',
+                      properties: {
+                        oneOf: {
+                          type: 'array',
+                        },
+                      },
+                    },
+                  ],
+                },
+              },
+            },
+          },
+        },
+      },
+    },
+    options: null,
+  },
+  function asyncApi2MessageIdUniqueness(targetVal, _) {
+    const results: IFunctionResult[] = [];
+    const messages = getAllMessages(targetVal);
+
+    const seenIds: unknown[] = [];
+    for (const { path, message } of messages) {
+      const maybeMessageId = retrieveMessageId(message);
+      if (maybeMessageId === undefined) {
+        continue;
+      }
+
+      if (seenIds.includes(maybeMessageId.messageId)) {
+        results.push({
+          message: '"messageId" must be unique across all the messages.',
+          path: [...path, ...maybeMessageId.path],
+        });
+      } else {
+        seenIds.push(maybeMessageId.messageId);
+      }
+    }
+
+    return results;
+  },
+);
diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2OperationIdUniqueness.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2OperationIdUniqueness.ts
index 92d081ad..23a1bfc2 100644
--- a/packages/rulesets/src/asyncapi/functions/asyncApi2OperationIdUniqueness.ts
+++ b/packages/rulesets/src/asyncapi/functions/asyncApi2OperationIdUniqueness.ts
@@ -1,8 +1,36 @@
 import { createRulesetFunction } from '@stoplight/spectral-core';
+import { isPlainObject } from '@stoplight/json';
 
 import { getAllOperations } from './utils/getAllOperations';
 
 import type { IFunctionResult } from '@stoplight/spectral-core';
+import type { JsonPath } from '@stoplight/types';
+
+function retrieveOperationId(operation: {
+  operationId?: string;
+  traits?: Array<{ operationId?: string }>;
+}): { operationId: string; path: JsonPath } | undefined {
+  if (Array.isArray(operation.traits)) {
+    for (let i = operation.traits.length - 1; i >= 0; i--) {
+      const trait = operation.traits[i];
+      if (isPlainObject(trait) && typeof trait.operationId === 'string') {
+        return {
+          operationId: trait.operationId,
+          path: ['traits', i, 'operationId'],
+        };
+      }
+    }
+  }
+
+  if (typeof operation.operationId === 'string') {
+    return {
+      operationId: operation.operationId,
+      path: ['operationId'],
+    };
+  }
+
+  return undefined;
+}
 
 export default createRulesetFunction<
   { channels: Record<string, { subscribe: Record<string, unknown>; publish: Record<string, unknown> }> },
@@ -33,18 +61,18 @@ export default createRulesetFunction<
 
     const seenIds: unknown[] = [];
     for (const { path, operation } of operations) {
-      if (!('operationId' in operation)) {
+      const maybeOperationId = retrieveOperationId(operation);
+      if (maybeOperationId === undefined) {
         continue;
       }
 
-      const operationId = (operation as { operationId: string }).operationId;
-      if (seenIds.includes(operationId)) {
+      if (seenIds.includes(maybeOperationId.operationId)) {
         results.push({
           message: '"operationId" must be unique across all the operations.',
-          path: [...path, 'operationId'],
+          path: [...path, ...maybeOperationId.path],
         });
       } else {
-        seenIds.push(operationId);
+        seenIds.push(maybeOperationId.operationId);
       }
     }
 
diff --git a/packages/rulesets/src/asyncapi/functions/utils/getAllMessages.ts b/packages/rulesets/src/asyncapi/functions/utils/getAllMessages.ts
new file mode 100644
index 00000000..f7a7bb60
--- /dev/null
+++ b/packages/rulesets/src/asyncapi/functions/utils/getAllMessages.ts
@@ -0,0 +1,40 @@
+import { isPlainObject } from '@stoplight/json';
+
+import { getAllOperations } from './getAllOperations';
+
+import type { JsonPath } from '@stoplight/types';
+
+type MessageObject = Record<string, unknown>;
+type AsyncAPI = {
+  channels?: Record<string, { subscribe?: Record<string, unknown>; publish?: Record<string, unknown> }>;
+};
+type Result = { path: JsonPath; message: MessageObject };
+
+export function* getAllMessages(asyncapi: AsyncAPI): IterableIterator<Result> {
+  for (const { path, operation } of getAllOperations(asyncapi)) {
+    if (!isPlainObject(operation)) {
+      continue;
+    }
+
+    const maybeMessage = operation.message;
+    if (!isPlainObject(maybeMessage)) {
+      continue;
+    }
+
+    if (Array.isArray(maybeMessage.oneOf)) {
+      for (const [index, message] of maybeMessage.oneOf.entries()) {
+        if (isPlainObject(message)) {
+          yield {
+            path: [...path, 'message', 'oneOf', index],
+            message,
+          };
+        }
+      }
+    } else {
+      yield {
+        path: [...path, 'message'],
+        message: maybeMessage,
+      };
+    }
+  }
+}
diff --git a/packages/rulesets/src/asyncapi/functions/utils/getAllOperations.ts b/packages/rulesets/src/asyncapi/functions/utils/getAllOperations.ts
index 3c689ea7..c22ca30d 100644
--- a/packages/rulesets/src/asyncapi/functions/utils/getAllOperations.ts
+++ b/packages/rulesets/src/asyncapi/functions/utils/getAllOperations.ts
@@ -2,15 +2,16 @@ import { isPlainObject } from '@stoplight/json';
 
 import type { JsonPath } from '@stoplight/types';
 
+type OperationObject = Record<string, unknown>;
 type AsyncAPI = {
-  channels?: Record<string, { subscribe?: Record<string, unknown>; publish?: Record<string, unknown> }>;
+  channels?: Record<string, { subscribe?: OperationObject; publish?: OperationObject }>;
 };
-type Operation = { path: JsonPath; kind: 'subscribe' | 'publish'; operation: Record<string, unknown> };
+type Result = { path: JsonPath; kind: 'subscribe' | 'publish'; operation: OperationObject };
 
-export function* getAllOperations(asyncapi: AsyncAPI): IterableIterator<Operation> {
+export function* getAllOperations(asyncapi: AsyncAPI): IterableIterator<Result> {
   const channels = asyncapi?.channels;
   if (!isPlainObject(channels)) {
-    return [];
+    return {};
   }
 
   for (const [channelAddress, channel] of Object.entries(channels)) {
diff --git a/packages/rulesets/src/asyncapi/index.ts b/packages/rulesets/src/asyncapi/index.ts
index 1596cf82..e7d387bc 100644
--- a/packages/rulesets/src/asyncapi/index.ts
+++ b/packages/rulesets/src/asyncapi/index.ts
@@ -12,6 +12,7 @@ import asyncApi2ChannelParameters from './functions/asyncApi2ChannelParameters';
 import asyncApi2ChannelServers from './functions/asyncApi2ChannelServers';
 import asyncApi2DocumentSchema from './functions/asyncApi2DocumentSchema';
 import asyncApi2MessageExamplesValidation from './functions/asyncApi2MessageExamplesValidation';
+import asyncApi2MessageIdUniqueness from './functions/asyncApi2MessageIdUniqueness';
 import asyncApi2OperationIdUniqueness from './functions/asyncApi2OperationIdUniqueness';
 import asyncApi2SchemaValidation from './functions/asyncApi2SchemaValidation';
 import asyncApi2PayloadValidation from './functions/asyncApi2PayloadValidation';
@@ -196,6 +197,16 @@ export default {
         function: asyncApi2MessageExamplesValidation,
       },
     },
+    'asyncapi-message-messageId-uniqueness': {
+      description: '"messageId" must be unique across all the messages.',
+      severity: 'error',
+      recommended: true,
+      type: 'validation',
+      given: '$',
+      then: {
+        function: asyncApi2MessageIdUniqueness,
+      },
+    },
     'asyncapi-operation-description': {
       description: 'Operation "description" must be present and non-empty string.',
       recommended: true,


Test patch:
diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-message-messageId-uniqueness.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-message-messageId-uniqueness.test.ts
new file mode 100644
index 00000000..eae05024
--- /dev/null
+++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-message-messageId-uniqueness.test.ts
@@ -0,0 +1,553 @@
+import { DiagnosticSeverity } from '@stoplight/types';
+import testRule from './__helpers__/tester';
+
+testRule('asyncapi-message-messageId-uniqueness', [
+  {
+    name: 'validate a correct object',
+    document: {
+      asyncapi: '2.4.0',
+      channels: {
+        someChannel1: {
+          subscribe: {
+            message: {
+              messageId: 'id1',
+            },
+          },
+        },
+        someChannel2: {
+          subscribe: {
+            message: {
+              messageId: 'id2',
+            },
+          },
+          publish: {
+            message: {
+              messageId: 'id3',
+            },
+          },
+        },
+      },
+    },
+    errors: [],
+  },
+
+  {
+    name: 'validate a correct object (oneOf case)',
+    document: {
+      asyncapi: '2.4.0',
+      channels: {
+        someChannel1: {
+          subscribe: {
+            message: {
+              messageId: 'id1',
+            },
+          },
+        },
+        someChannel2: {
+          subscribe: {
+            message: {
+              oneOf: [
+                {
+                  messageId: 'id2',
+                },
+                {
+                  messageId: 'id3',
+                },
+              ],
+            },
+          },
+          publish: {
+            message: {
+              messageId: 'id4',
+            },
+          },
+        },
+      },
+    },
+    errors: [],
+  },
+
+  {
+    name: 'validate a correct object (using traits)',
+    document: {
+      asyncapi: '2.4.0',
+      channels: {
+        someChannel1: {
+          subscribe: {
+            message: {
+              messageId: 'id1',
+            },
+          },
+        },
+        someChannel2: {
+          subscribe: {
+            message: {
+              messageId: 'id2',
+            },
+          },
+          publish: {
+            message: {
+              messageId: 'id1',
+              traits: [
+                {
+                  messageId: 'id2',
+                },
+                {
+                  messageId: 'id3',
+                },
+              ],
+            },
+          },
+        },
+      },
+    },
+    errors: [],
+  },
+
+  {
+    name: 'validate a correct object (oneOf case using traits)',
+    document: {
+      asyncapi: '2.4.0',
+      channels: {
+        someChannel1: {
+          subscribe: {
+            message: {
+              messageId: 'id1',
+            },
+          },
+        },
+        someChannel2: {
+          subscribe: {
+            message: {
+              oneOf: [
+                {
+                  messageId: 'id1',
+                  traits: [
+                    {
+                      messageId: 'id3',
+                    },
+                    {
+                      messageId: 'id2',
+                    },
+                  ],
+                },
+                {
+                  messageId: 'id3',
+                },
+              ],
+            },
+          },
+          publish: {
+            message: {
+              messageId: 'id4',
+            },
+          },
+        },
+      },
+    },
+    errors: [],
+  },
+
+  {
+    name: 'return errors on different messages same id',
+    document: {
+      asyncapi: '2.4.0',
+      channels: {
+        someChannel1: {
+          subscribe: {
+            message: {
+              messageId: 'id1',
+            },
+          },
+        },
+        someChannel2: {
+          subscribe: {
+            message: {
+              messageId: 'id2',
+            },
+          },
+          publish: {
+            message: {
+              messageId: 'id1',
+            },
+          },
+        },
+      },
+    },
+    errors: [
+      {
+        message: '"messageId" must be unique across all the messages.',
+        path: ['channels', 'someChannel2', 'publish', 'message', 'messageId'],
+        severity: DiagnosticSeverity.Error,
+      },
+    ],
+  },
+
+  {
+    name: 'return errors on same path messages same id',
+    document: {
+      asyncapi: '2.4.0',
+      channels: {
+        someChannel1: {
+          subscribe: {
+            message: {
+              messageId: 'id1',
+            },
+          },
+        },
+        someChannel2: {
+          subscribe: {
+            message: {
+              messageId: 'id2',
+            },
+          },
+          publish: {
+            message: {
+              messageId: 'id2',
+            },
+          },
+        },
+      },
+    },
+    errors: [
+      {
+        message: '"messageId" must be unique across all the messages.',
+        path: ['channels', 'someChannel2', 'publish', 'message', 'messageId'],
+        severity: DiagnosticSeverity.Error,
+      },
+    ],
+  },
+
+  {
+    name: 'return errors on different messages (using traits) same id',
+    document: {
+      asyncapi: '2.4.0',
+      channels: {
+        someChannel1: {
+          subscribe: {
+            message: {
+              messageId: 'id3',
+              traits: [
+                {
+                  messageId: 'id4',
+                },
+                {
+                  messageId: 'id1',
+                },
+              ],
+            },
+          },
+        },
+        someChannel2: {
+          subscribe: {
+            message: {
+              messageId: 'id2',
+            },
+          },
+          publish: {
+            message: {
+              messageId: 'id1',
+            },
+          },
+        },
+      },
+    },
+    errors: [
+      {
+        message: '"messageId" must be unique across all the messages.',
+        path: ['channels', 'someChannel2', 'publish', 'message', 'messageId'],
+        severity: DiagnosticSeverity.Error,
+      },
+    ],
+  },
+
+  {
+    name: 'return errors on same path messages (using traits) same id',
+    document: {
+      asyncapi: '2.0.0',
+      channels: {
+        someChannel1: {
+          subscribe: {
+            message: {
+              messageId: 'id1',
+            },
+          },
+        },
+        someChannel2: {
+          subscribe: {
+            message: {
+              messageId: 'id2',
+            },
+          },
+          publish: {
+            message: {
+              messageId: 'id3',
+              traits: [
+                {
+                  messageId: 'id4',
+                },
+                {
+                  messageId: 'id2',
+                },
+              ],
+            },
+          },
+        },
+      },
+    },
+    errors: [
+      {
+        message: '"messageId" must be unique across all the messages.',
+        path: ['channels', 'someChannel2', 'publish', 'message', 'traits', '1', 'messageId'],
+        severity: DiagnosticSeverity.Error,
+      },
+    ],
+  },
+
+  {
+    name: 'return errors on different messages (oneOf case using traits) same id',
+    document: {
+      asyncapi: '2.0.0',
+      channels: {
+        someChannel1: {
+          subscribe: {
+            message: {
+              messageId: 'id1',
+            },
+          },
+        },
+        someChannel2: {
+          subscribe: {
+            message: {
+              messageId: 'id2',
+            },
+          },
+          publish: {
+            message: {
+              oneOf: [
+                {
+                  messageId: 'id3',
+                  traits: [
+                    {
+                      messageId: 'id4',
+                    },
+                    {
+                      messageId: 'id2',
+                    },
+                  ],
+                },
+                {
+                  messageId: 'id3',
+                  traits: [
+                    {
+                      messageId: 'id1',
+                    },
+                  ],
+                },
+              ],
+            },
+          },
+        },
+      },
+    },
+    errors: [
+      {
+        message: '"messageId" must be unique across all the messages.',
+        path: ['channels', 'someChannel2', 'publish', 'message', 'oneOf', '0', 'traits', '1', 'messageId'],
+        severity: DiagnosticSeverity.Error,
+      },
+      {
+        message: '"messageId" must be unique across all the messages.',
+        path: ['channels', 'someChannel2', 'publish', 'message', 'oneOf', '1', 'traits', '0', 'messageId'],
+        severity: DiagnosticSeverity.Error,
+      },
+    ],
+  },
+
+  {
+    name: 'return errors on different messages same id (more than two messages)',
+    document: {
+      asyncapi: '2.4.0',
+      channels: {
+        someChannel1: {
+          subscribe: {
+            message: {
+              messageId: 'id1',
+            },
+          },
+        },
+        someChannel2: {
+          subscribe: {
+            message: {
+              messageId: 'id2',
+            },
+          },
+          publish: {
+            message: {
+              messageId: 'id1',
+            },
+          },
+        },
+        someChannel3: {
+          subscribe: {
+            message: {
+              messageId: 'id1',
+            },
+          },
+          publish: {
+            message: {
+              messageId: 'id1',
+            },
+          },
+        },
+      },
+    },
+    errors: [
+      {
+        message: '"messageId" must be unique across all the messages.',
+        path: ['channels', 'someChannel2', 'publish', 'message', 'messageId'],
+        severity: DiagnosticSeverity.Error,
+      },
+      {
+        message: '"messageId" must be unique across all the messages.',
+        path: ['channels', 'someChannel3', 'subscribe', 'message', 'messageId'],
+        severity: DiagnosticSeverity.Error,
+      },
+      {
+        message: '"messageId" must be unique across all the messages.',
+        path: ['channels', 'someChannel3', 'publish', 'message', 'messageId'],
+        severity: DiagnosticSeverity.Error,
+      },
+    ],
+  },
+
+  {
+    name: 'return errors on different messages same id (more than two messages and using traits)',
+    document: {
+      asyncapi: '2.0.0',
+      channels: {
+        someChannel1: {
+          subscribe: {
+            message: {
+              messageId: 'id1',
+            },
+          },
+        },
+        someChannel2: {
+          subscribe: {
+            message: {
+              messageId: 'id2',
+            },
+          },
+          publish: {
+            message: {
+              messageId: 'id3',
+              traits: [
+                {
+                  messageId: 'id4',
+                },
+                {
+                  messageId: 'id1',
+                },
+              ],
+            },
+          },
+        },
+        someChannel3: {
+          subscribe: {
+            message: {
+              messageId: 'id1',
+              traits: [
+                {
+                  messageId: 'id5',
+                },
+                {
+                  messageId: 'id2',
+                },
+              ],
+            },
+          },
+          publish: {
+            message: {
+              messageId: 'id2',
+              traits: [
+                {
+                  messageId: 'id1',
+                },
+              ],
+            },
+          },
+        },
+      },
+    },
+    errors: [
+      {
+        message: '"messageId" must be unique across all the messages.',
+        path: ['channels', 'someChannel2', 'publish', 'message', 'traits', '1', 'messageId'],
+        severity: DiagnosticSeverity.Error,
+      },
+      {
+        message: '"messageId" must be unique across all the messages.',
+        path: ['channels', 'someChannel3', 'subscribe', 'message', 'traits', '1', 'messageId'],
+        severity: DiagnosticSeverity.Error,
+      },
+      {
+        message: '"messageId" must be unique across all the messages.',
+        path: ['channels', 'someChannel3', 'publish', 'message', 'traits', '0', 'messageId'],
+        severity: DiagnosticSeverity.Error,
+      },
+    ],
+  },
+
+  {
+    name: 'do not check messageId in the components',
+    document: {
+      asyncapi: '2.4.0',
+      channels: {
+        someChannel1: {
+          subscribe: {
+            message: {
+              messageId: 'id1',
+            },
+          },
+        },
+        someChannel2: {
+          subscribe: {
+            message: {
+              messageId: 'id2',
+            },
+          },
+          publish: {
+            message: {
+              messageId: 'id3',
+            },
+          },
+        },
+      },
+      components: {
+        channels: {
+          someChannel1: {
+            subscribe: {
+              message: {
+                messageId: 'id1',
+              },
+            },
+          },
+          someChannel2: {
+            subscribe: {
+              message: {
+                messageId: 'id2',
+              },
+            },
+            publish: {
+              message: {
+                messageId: 'id1',
+              },
+            },
+          },
+        },
+      },
+    },
+    errors: [],
+  },
+]);
diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-operation-operationId-uniqueness.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-operation-operationId-uniqueness.test.ts
index a8314664..c9380211 100644
--- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-operation-operationId-uniqueness.test.ts
+++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-operation-operationId-uniqueness.test.ts
@@ -25,6 +25,37 @@ testRule('asyncapi-operation-operationId-uniqueness', [
     errors: [],
   },
 
+  {
+    name: 'validate a correct object (using traits)',
+    document: {
+      asyncapi: '2.0.0',
+      channels: {
+        someChannel1: {
+          subscribe: {
+            operationId: 'id1',
+          },
+        },
+        someChannel2: {
+          subscribe: {
+            operationId: 'id2',
+          },
+          publish: {
+            operationId: 'id1',
+            traits: [
+              {
+                operationId: 'id2',
+              },
+              {
+                operationId: 'id3',
+              },
+            ],
+          },
+        },
+      },
+    },
+    errors: [],
+  },
+
   {
     name: 'return errors on different operations same id',
     document: {
@@ -83,6 +114,80 @@ testRule('asyncapi-operation-operationId-uniqueness', [
     ],
   },
 
+  {
+    name: 'return errors on different operations (using traits) same id',
+    document: {
+      asyncapi: '2.0.0',
+      channels: {
+        someChannel1: {
+          subscribe: {
+            operationId: 'id3',
+            traits: [
+              {
+                operationId: 'id4',
+              },
+              {
+                operationId: 'id1',
+              },
+            ],
+          },
+        },
+        someChannel2: {
+          subscribe: {
+            operationId: 'id2',
+          },
+          publish: {
+            operationId: 'id1',
+          },
+        },
+      },
+    },
+    errors: [
+      {
+        message: '"operationId" must be unique across all the operations.',
+        path: ['channels', 'someChannel2', 'publish', 'operationId'],
+        severity: DiagnosticSeverity.Error,
+      },
+    ],
+  },
+
+  {
+    name: 'return errors on same path operations (using traits) same id',
+    document: {
+      asyncapi: '2.0.0',
+      channels: {
+        someChannel1: {
+          subscribe: {
+            operationId: 'id1',
+          },
+        },
+        someChannel2: {
+          subscribe: {
+            operationId: 'id2',
+          },
+          publish: {
+            operationId: 'id3',
+            traits: [
+              {
+                operationId: 'id4',
+              },
+              {
+                operationId: 'id2',
+              },
+            ],
+          },
+        },
+      },
+    },
+    errors: [
+      {
+        message: '"operationId" must be unique across all the operations.',
+        path: ['channels', 'someChannel2', 'publish', 'traits', '1', 'operationId'],
+        severity: DiagnosticSeverity.Error,
+      },
+    ],
+  },
+
   {
     name: 'return errors on different operations same id (more than two operations)',
     document: {
@@ -130,6 +235,74 @@ testRule('asyncapi-operation-operationId-uniqueness', [
     ],
   },
 
+  {
+    name: 'return errors on different operations same id (more than two operations and using traits)',
+    document: {
+      asyncapi: '2.0.0',
+      channels: {
+        someChannel1: {
+          subscribe: {
+            operationId: 'id1',
+          },
+        },
+        someChannel2: {
+          subscribe: {
+            operationId: 'id2',
+          },
+          publish: {
+            operationId: 'id3',
+            traits: [
+              {
+                operationId: 'id4',
+              },
+              {
+                operationId: 'id1',
+              },
+            ],
+          },
+        },
+        someChannel3: {
+          subscribe: {
+            operationId: 'id1',
+            traits: [
+              {
+                operationId: 'id5',
+              },
+              {
+                operationId: 'id2',
+              },
+            ],
+          },
+          publish: {
+            operationId: 'id2',
+            traits: [
+              {
+                operationId: 'id1',
+              },
+            ],
+          },
+        },
+      },
+    },
+    errors: [
+      {
+        message: '"operationId" must be unique across all the operations.',
+        path: ['channels', 'someChannel2', 'publish', 'traits', '1', 'operationId'],
+        severity: DiagnosticSeverity.Error,
+      },
+      {
+        message: '"operationId" must be unique across all the operations.',
+        path: ['channels', 'someChannel3', 'subscribe', 'traits', '1', 'operationId'],
+        severity: DiagnosticSeverity.Error,
+      },
+      {
+        message: '"operationId" must be unique across all the operations.',
+        path: ['channels', 'someChannel3', 'publish', 'traits', '0', 'operationId'],
+        severity: DiagnosticSeverity.Error,
+      },
+    ],
+  },
+
   {
     name: 'do not check operationId in the components',
     document: {