Repository: protofire/solhint
Pull request #626 (2025-01-16 20:14:42)

feature: duplicated import rule


Patch:
diff --git a/conf/rulesets/solhint-all.js b/conf/rulesets/solhint-all.js
index 730cdd1..3b964d3 100644
--- a/conf/rulesets/solhint-all.js
+++ b/conf/rulesets/solhint-all.js
@@ -36,6 +36,7 @@ module.exports = Object.freeze({
     'gas-strict-inequalities': 'warn',
     'gas-struct-packing': 'warn',
     'comprehensive-interface': 'warn',
+    'duplicated-imports': 'warn',
     quotes: ['error', 'double'],
     'const-name-snakecase': 'warn',
     'contract-name-capwords': 'warn',
diff --git a/docs/rules.md b/docs/rules.md
index 1ebe8de..603dba4 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -28,6 +28,7 @@ title:       "Rule Index of Solhint"
 | Rule Id                                                                              | Error                                                                                                         | Recommended  | Deprecated  |
 | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | ------------ | ----------- |
 | [interface-starts-with-i](./rules/naming/interface-starts-with-i.md)                 | Solidity Interfaces names should start with an `I`                                                            |              |             |
+| [duplicated-imports](./rules/miscellaneous/duplicated-imports.md)                    | Check if an import is done twice in the same file and there is no alias                                       |              |             |
 | [const-name-snakecase](./rules/naming/const-name-snakecase.md)                       | Constant name must be in capitalized SNAKE_CASE. (Does not check IMMUTABLES, use immutable-vars-naming)       | $~~~~~~~~$✔️ |             |
 | [contract-name-capwords](./rules/naming/contract-name-capwords.md)                   | Contract, Structs and Enums should be in CapWords.                                                            | $~~~~~~~~$✔️ |             |
 | [event-name-capwords](./rules/naming/event-name-capwords.md)                         | Event name must be in CapWords.                                                                               | $~~~~~~~~$✔️ |             |
diff --git a/docs/rules/miscellaneous/duplicated-imports.md b/docs/rules/miscellaneous/duplicated-imports.md
new file mode 100644
index 0000000..6088c1a
--- /dev/null
+++ b/docs/rules/miscellaneous/duplicated-imports.md
@@ -0,0 +1,41 @@
+---
+warning:     "This is a dynamically generated file. Do not edit manually."
+layout:      "default"
+title:       "duplicated-imports | Solhint"
+---
+
+# duplicated-imports
+![Category Badge](https://img.shields.io/badge/-Style%20Guide%20Rules-informational)
+![Default Severity Badge warn](https://img.shields.io/badge/Default%20Severity-warn-yellow)
+
+## Description
+Check if an import is done twice in the same file and there is no alias
+
+## Options
+This rule accepts a string option of rule severity. Must be one of "error", "warn", "off". Defaults to warn.
+
+### Example Config
+```json
+{
+  "rules": {
+    "duplicated-imports": "warn"
+  }
+}
+```
+
+### Notes
+- Rule reports "(inline) duplicated" if the same object is imported more than once in the same import statement
+- Rule reports "(globalSamePath) duplicated" if the same object is imported on another import statement from same location
+- Rule reports "(globalDiffPath) duplicated" if the same object is imported on another import statement, from other location, but no alias
+- Rule does NOT support this kind of import "import * as Alias from "./filename.sol"
+
+## Examples
+This rule does not have examples.
+
+## Version
+This rule is introduced in the latest version.
+
+## Resources
+- [Rule source](https://github.com/protofire/solhint/blob/master/lib/rules/miscellaneous/duplicated-imports.js)
+- [Document source](https://github.com/protofire/solhint/blob/master/docs/rules/miscellaneous/duplicated-imports.md)
+- [Test cases](https://github.com/protofire/solhint/blob/master/test/rules/miscellaneous/duplicated-imports.js)
diff --git a/lib/rules/miscellaneous/duplicated-imports.js b/lib/rules/miscellaneous/duplicated-imports.js
new file mode 100644
index 0000000..567f9d9
--- /dev/null
+++ b/lib/rules/miscellaneous/duplicated-imports.js
@@ -0,0 +1,251 @@
+const path = require('path')
+const BaseChecker = require('../base-checker')
+const { severityDescription } = require('../../doc/utils')
+
+const DEFAULT_SEVERITY = 'warn'
+
+const ruleId = 'duplicated-imports'
+const meta = {
+  type: 'miscellaneous',
+
+  docs: {
+    description: `Check if an import is done twice in the same file and there is no alias`,
+    category: 'Style Guide Rules',
+    options: [
+      {
+        description: severityDescription,
+        default: DEFAULT_SEVERITY,
+      },
+    ],
+    notes: [
+      {
+        note: 'Rule reports "(inline) duplicated" if the same object is imported more than once in the same import statement',
+      },
+      {
+        note: 'Rule reports "(globalSamePath) duplicated" if the same object is imported on another import statement from same location',
+      },
+      {
+        note: 'Rule reports "(globalDiffPath) duplicated" if the same object is imported on another import statement, from other location, but no alias',
+      },
+      {
+        note: 'Rule does NOT support this kind of import "import * as Alias from "./filename.sol"',
+      },
+    ],
+  },
+
+  isDefault: false,
+  recommended: false,
+  defaultSetup: 'warn',
+  fixable: true,
+  schema: null,
+}
+
+class DuplicatedImportsChecker extends BaseChecker {
+  constructor(reporter) {
+    super(reporter, ruleId, meta)
+
+    this.imports = []
+  }
+
+  ImportDirective(node) {
+    const normalizedPath = this.normalizePath(node.path)
+
+    const importStatement = {
+      path: '',
+      objectNames: [],
+    }
+
+    importStatement.path = normalizedPath
+    importStatement.objectNames = node.symbolAliases
+      ? node.symbolAliases
+      : this.getObjectName(normalizedPath)
+
+    this.imports.push(importStatement)
+  }
+
+  'SourceUnit:exit'(node) {
+    const duplicates = this.findDuplicates(this.imports)
+
+    for (let i = 0; i < duplicates.length; i++) {
+      this.error(node, `Duplicated Import (${duplicates[i].type}) ${duplicates[i].name}`)
+    }
+  }
+
+  getObjectName(normalizedPath) {
+    // get file name
+    const fileNameWithExtension = path.basename(normalizedPath)
+    // Remove extension
+    const objectName = fileNameWithExtension.replace('.sol', '')
+    return [[objectName, null]]
+  }
+
+  normalizePath(path) {
+    if (path.startsWith('../')) {
+      return `./${path}`
+    }
+    return path
+  }
+
+  findInlineDuplicates(data) {
+    const inlineDuplicates = []
+
+    data.forEach((entry) => {
+      const path = entry.path
+      // To track object names
+      const objectNamesSet = new Set()
+
+      entry.objectNames.forEach(([objectName]) => {
+        // If object name already been found , it is a duplicated
+        if (objectNamesSet.has(objectName)) {
+          inlineDuplicates.push({
+            name: objectName,
+            type: 'inline',
+            paths: [path],
+          })
+        } else {
+          // If it is not found before, we add it
+          objectNamesSet.add(objectName)
+        }
+      })
+    })
+
+    return inlineDuplicates
+  }
+
+  finGlobalDuplicatesSamePath(data) {
+    const duplicates = []
+
+    // Loop through data
+    data.forEach((entry) => {
+      const path = entry.path
+
+      // Object to track object names on each path
+      const objectNamesMap = {}
+
+      // Loop through each objectName of current object
+      entry.objectNames.forEach(([objectName]) => {
+        if (!objectNamesMap[objectName]) {
+          objectNamesMap[objectName] = []
+        }
+        objectNamesMap[objectName].push(path)
+      })
+
+      // Compare this object with the rest to detect duplicates
+      data.forEach((otherEntry) => {
+        if (otherEntry !== entry) {
+          otherEntry.objectNames.forEach(([objectName]) => {
+            if (
+              objectNamesMap[objectName] &&
+              objectNamesMap[objectName].includes(otherEntry.path)
+            ) {
+              // Add path only if it is not present
+              const existingDuplicate = duplicates.find(
+                (duplicate) =>
+                  duplicate.name === objectName &&
+                  duplicate.type === 'global' &&
+                  duplicate.paths.includes(entry.path)
+              )
+
+              if (!existingDuplicate) {
+                duplicates.push({
+                  name: objectName,
+                  type: 'globalSamePath',
+                  paths: [entry.path], // Just add path once, it is always the same
+                })
+              }
+            }
+          })
+        }
+      })
+    })
+
+    return duplicates
+  }
+
+  finGlobalDuplicatesDiffPathNoAlias(data) {
+    const duplicates = []
+
+    // Loop through data
+    data.forEach((entry) => {
+      // Object to track names on each path
+      entry.objectNames.forEach(([objectName, alias]) => {
+        // Only compare if there is no alias
+        if (!alias) {
+          // Go through rest of objects to search for duplicates
+          data.forEach((otherEntry) => {
+            if (otherEntry !== entry) {
+              otherEntry.objectNames.forEach(([otherObjectName, otherAlias]) => {
+                // If object name is the same, has no alias and different path
+                if (
+                  objectName === otherObjectName &&
+                  !otherAlias &&
+                  entry.path !== otherEntry.path
+                ) {
+                  // Check if the name is already in the duplicated array
+                  const existingDuplicate = duplicates.find(
+                    (duplicate) =>
+                      duplicate.name === objectName &&
+                      duplicate.type === 'global' &&
+                      duplicate.paths.includes(entry.path)
+                  )
+
+                  // Add new object if doesn't exist
+                  if (!existingDuplicate) {
+                    duplicates.push({
+                      name: objectName,
+                      type: 'globalDiffPath',
+                      paths: [entry.path, otherEntry.path],
+                    })
+                  }
+
+                  // Add path if already exists
+                  if (existingDuplicate && !existingDuplicate.paths.includes(otherEntry.path)) {
+                    existingDuplicate.paths.push(otherEntry.path)
+                  }
+                }
+              })
+            }
+          })
+        }
+      })
+    })
+
+    return duplicates
+  }
+
+  removeDuplicatedObjects(data) {
+    const uniqueData = data.filter((value, index, self) => {
+      // Order path arrays to be compared later
+      const sortedPaths = value.paths.slice().sort()
+
+      return (
+        index ===
+        self.findIndex(
+          (t) =>
+            t.name === value.name &&
+            t.type === value.type &&
+            // Compare ordered arrays of paths
+            JSON.stringify(t.paths.slice().sort()) === JSON.stringify(sortedPaths)
+        )
+      )
+    })
+
+    return uniqueData
+  }
+
+  findDuplicates(data) {
+    /// @TODO THIS LOGIC CAN BE IMPROVED - Not done due lack of time
+
+    const duplicates1 = this.findInlineDuplicates(data)
+
+    const duplicates2 = this.finGlobalDuplicatesSamePath(data)
+
+    const duplicates3 = this.finGlobalDuplicatesDiffPathNoAlias(data)
+
+    const duplicates = this.removeDuplicatedObjects(duplicates1.concat(duplicates2, duplicates3))
+
+    return duplicates
+  }
+}
+
+module.exports = DuplicatedImportsChecker
diff --git a/lib/rules/miscellaneous/index.js b/lib/rules/miscellaneous/index.js
index 680f691..0edeb85 100644
--- a/lib/rules/miscellaneous/index.js
+++ b/lib/rules/miscellaneous/index.js
@@ -1,9 +1,11 @@
 const QuotesChecker = require('./quotes')
 const ComprehensiveInterfaceChecker = require('./comprehensive-interface')
+const DuplicatedImportsChecker = require('./duplicated-imports')
 
 module.exports = function checkers(reporter, config, tokens) {
   return [
     new QuotesChecker(reporter, config, tokens),
     new ComprehensiveInterfaceChecker(reporter, config, tokens),
+    new DuplicatedImportsChecker(reporter),
   ]
 }
diff --git a/solhint.js b/solhint.js
index 6c17266..01587e1 100755
--- a/solhint.js
+++ b/solhint.js
@@ -4,6 +4,7 @@ const _ = require('lodash')
 const fs = require('fs')
 const process = require('process')
 const readline = require('readline')
+const chalk = require('chalk')
 
 const linter = require('./lib/index')
 const { loadConfig } = require('./lib/config/config-file')
@@ -306,7 +307,29 @@ function printReports(reports, formatter) {
   }
 
   const fullReport = formatter(reports) + (finalMessage || '')
-  if (!program.opts().quiet) console.log(fullReport)
+
+  if (!program.opts().quiet) {
+    console.log(fullReport)
+    if (fullReport && !program.opts().formatter) {
+      console.log(
+        chalk.italic.bgYellow.black.bold(
+          ' -------------------------------------------------------------------------- '
+        )
+      )
+
+      console.log(
+        chalk.italic.bgYellow.black.bold(
+          ' ===> Join SOLHINT Community at: https://discord.com/invite/4TYGq3zpjs <=== '
+        )
+      )
+
+      console.log(
+        chalk.italic.bgYellow.black.bold(
+          ' -------------------------------------------------------------------------- \n'
+        )
+      )
+    }
+  }
 
   if (program.opts().save) {
     writeStringToFile(fullReport)


Test patch:
diff --git a/e2e/autofix-test.js b/e2e/autofix-test.js
index f57ae85..2c80022 100644
--- a/e2e/autofix-test.js
+++ b/e2e/autofix-test.js
@@ -145,7 +145,7 @@ describe('e2e', function () {
 
           const reportLines = stdout.split('\n')
           const finalLine = '5 problems (5 errors, 0 warnings)'
-          expect(reportLines[reportLines.length - 3]).to.contain(finalLine)
+          expect(reportLines[reportLines.length - 7]).to.contain(finalLine)
 
           result = compareTextFiles(currentFile, afterFixFile)
           expect(result).to.be.true
@@ -194,7 +194,7 @@ describe('e2e', function () {
         it('should get the right report (2)', () => {
           const reportLines = stdout.split('\n')
           const finalLine = '27 problems (27 errors, 0 warnings)'
-          expect(reportLines[reportLines.length - 3]).to.contain(finalLine)
+          expect(reportLines[reportLines.length - 7]).to.contain(finalLine)
         })
       })
 
@@ -240,7 +240,7 @@ describe('e2e', function () {
         it('should get the right report (3)', () => {
           const reportLines = stdout.split('\n')
           const finalLine = '9 problems (9 errors, 0 warnings)'
-          expect(reportLines[reportLines.length - 3]).to.contain(finalLine)
+          expect(reportLines[reportLines.length - 7]).to.contain(finalLine)
         })
       })
 
@@ -286,7 +286,7 @@ describe('e2e', function () {
         it('should get the right report (4)', () => {
           const reportLines = stdout.split('\n')
           const finalLine = '19 problems (19 errors, 0 warnings)'
-          expect(reportLines[reportLines.length - 3]).to.contain(finalLine)
+          expect(reportLines[reportLines.length - 7]).to.contain(finalLine)
         })
       })
 
@@ -332,7 +332,7 @@ describe('e2e', function () {
         it('should get the right report (5)', () => {
           const reportLines = stdout.split('\n')
           const finalLine = '11 problems (11 errors, 0 warnings)'
-          expect(reportLines[reportLines.length - 3]).to.contain(finalLine)
+          expect(reportLines[reportLines.length - 7]).to.contain(finalLine)
         })
       })
 
@@ -379,7 +379,7 @@ describe('e2e', function () {
         it('should get the right report (6)', () => {
           const reportLines = stdout.split('\n')
           const finalLine = '8 problems (8 errors, 0 warnings)'
-          expect(reportLines[reportLines.length - 3]).to.contain(finalLine)
+          expect(reportLines[reportLines.length - 7]).to.contain(finalLine)
         })
       })
 
@@ -419,7 +419,7 @@ describe('e2e', function () {
         it('should get the right report (6)', () => {
           const reportLines = stdout.split('\n')
           const finalLine = '8 problems (8 errors, 0 warnings)'
-          expect(reportLines[reportLines.length - 3]).to.contain(finalLine)
+          expect(reportLines[reportLines.length - 7]).to.contain(finalLine)
         })
       })
 
@@ -466,7 +466,7 @@ describe('e2e', function () {
       it('should get the right report (7)', () => {
         const reportLines = stdout.split('\n')
         const finalLine = '3 problems (3 errors, 0 warnings)'
-        expect(reportLines[reportLines.length - 3]).to.contain(finalLine)
+        expect(reportLines[reportLines.length - 7]).to.contain(finalLine)
       })
     })
 
@@ -512,7 +512,7 @@ describe('e2e', function () {
       it('should get the right report (8)', () => {
         const reportLines = stdout.split('\n')
         const finalLine = '5 problems (5 errors, 0 warnings)'
-        expect(reportLines[reportLines.length - 3]).to.contain(finalLine)
+        expect(reportLines[reportLines.length - 7]).to.contain(finalLine)
       })
     })
 
@@ -558,7 +558,7 @@ describe('e2e', function () {
       it('should get the right report (9)', () => {
         const reportLines = stdout.split('\n')
         const finalLine = '6 problems (6 errors, 0 warnings)'
-        expect(reportLines[reportLines.length - 3]).to.contain(finalLine)
+        expect(reportLines[reportLines.length - 7]).to.contain(finalLine)
       })
     })
 
@@ -604,7 +604,7 @@ describe('e2e', function () {
         it('should get the right report (10)', () => {
           const reportLines = stdout.split('\n')
           const finalLine = '18 problems (18 errors, 0 warnings)'
-          expect(reportLines[reportLines.length - 3]).to.contain(finalLine)
+          expect(reportLines[reportLines.length - 7]).to.contain(finalLine)
         })
       })
 
@@ -649,7 +649,7 @@ describe('e2e', function () {
     //     it('should get the right report (11)', () => {
     //       const reportLines = stdout.split('\n')
     //       const finalLine = '12 problems (12 errors, 0 warnings)'
-    //       expect(reportLines[reportLines.length - 3]).to.contain(finalLine)
+    //       expect(reportLines[reportLines.length - 7]).to.contain(finalLine)
     //     })
     //   })
 
diff --git a/test/fixtures/miscellaneous/duplicated-imports-data.js b/test/fixtures/miscellaneous/duplicated-imports-data.js
new file mode 100644
index 0000000..605d438
--- /dev/null
+++ b/test/fixtures/miscellaneous/duplicated-imports-data.js
@@ -0,0 +1,275 @@
+const { multiLine } = require('../../common/contract-builder')
+
+const noDuplicates = [
+  {
+    name: 'No Duplicates1',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import {Afool} from './Afool.sol';",
+      "import {Afool2} from './Afool2.sol';",
+      'contract Test { }'
+    ),
+  },
+  {
+    name: 'No Duplicates2',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import {Afool as Bfool} from './Afool.sol';",
+      'contract Test { }'
+    ),
+  },
+  {
+    name: 'No Duplicates4',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import {Afool, Cfool} from './Afool.sol';",
+      "import {Cfool as Dfool} from './Cfool.sol';",
+      'contract Test { }'
+    ),
+  },
+  {
+    name: 'No Duplicates6',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import './SimpleLibrary.sol';",
+      "import {SimpleLibrary as lib} from './SimpleLibrary2.sol';",
+      'contract Test { }'
+    ),
+  },
+  {
+    name: 'No Duplicates7',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import {SimpleLibrary} from './SimpleLibrary.sol';",
+      'contract Test { }'
+    ),
+  },
+  {
+    name: 'No Duplicates8',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import {LibraryA} from './LibraryA.sol';",
+      "import {LibraryB} from './LibraryB.sol';",
+      'contract Test { }'
+    ),
+  },
+]
+
+const duplicates = [
+  {
+    name: 'Duplicates1',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import {Afool, Afool as Bfool} from './Afool.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 1,
+    message: ['(inline) Afool'], // Inline duplication
+  },
+  {
+    name: 'Duplicates2',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import {Afool} from './Afool.sol';",
+      "import {Afool} from './Afool.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 1,
+    message: ['(globalSamePath) Afool'], // Global duplication within the same path
+  },
+  {
+    name: 'Duplicates3',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import {Afool} from './Afool.sol';",
+      "import {Afool as FoolAlias} from './Afool.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 1,
+    message: ['(globalSamePath) Afool'], // Global duplication with alias
+  },
+  {
+    name: 'Duplicates4',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import './SimpleLibrary.sol';",
+      "import {SimpleLibrary} from './folder/SimpleLibrary.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 1,
+    message: ['(globalDiffPath) SimpleLibrary'], // Import with and without name specification
+  },
+  {
+    name: 'Duplicates5',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import {IXTokenFactory} from '../../token/interfaces/IXTokenFactory.sol';",
+      "import {IXTokenFactory} from '../../token/interfaces/IXTokenFactory2.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 1,
+    message: ['(globalDiffPath) IXTokenFactory'], // Global duplication
+  },
+  {
+    name: 'Duplicates6',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import {IXTokenFactory} from '../../token/interfaces/IXTokenFactory.sol';",
+      "import {IXTokenFactory, IXTokenFactory as AliasFactory} from '../../token/interfaces/IXTokenFactory.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 2,
+    message: ['(inline) IXTokenFactory', '(globalSamePath) IXTokenFactory'], // Mixed inline and global duplication
+  },
+  {
+    name: 'Duplicates7',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import {SharedLib} from './LibraryA.sol';",
+      "import {SharedLib} from './LibraryB.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 1,
+    message: ['(globalDiffPath) SharedLib'], // Same object name from different libraries
+  },
+  {
+    name: 'Duplicates8',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import {Token, Token as Tkn} from './LibraryA.sol';",
+      "import {Token} from './LibraryB.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 2,
+    message: ['(inline) Token', '(globalDiffPath) Token'], // Mixed inline and global duplication
+  },
+  {
+    name: 'Duplicates9',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import './LibraryA.sol';",
+      "import {LibraryA} from './LibraryB.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 1,
+    message: ['(globalDiffPath) LibraryA'], // Import with and without name specification different libraries
+  },
+  {
+    name: 'Duplicates10',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import './LibraryA.sol';",
+      "import {LibraryA} from './LibraryA.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 1,
+    message: ['(globalSamePath) LibraryA'], // Import with and without name specification same libraries
+  },
+  {
+    name: 'Duplicates11',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import {SharedLib} from '../LibraryA.sol';",
+      "import {SharedLib} from './nested/LibraryA.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 1,
+    message: ['(globalDiffPath) SharedLib'], // Same object imported from different nested paths
+  },
+  {
+    name: 'Duplicates12',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import '@openzeppelin/contracts/token/ERC20/ERC20.sol';",
+      "import {ERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 1,
+    message: ['(globalSamePath) ERC20'], // Standard library duplicate
+  },
+  {
+    name: 'Duplicates13',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import '../token/interfaces/IXTokenWrapper.sol';",
+      "import {IXTokenWrapper} from '../token/interfaces/IXTokenWrapper.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 1,
+    message: ['(globalSamePath) IXTokenWrapper'], // Import with and without name specification
+  },
+  {
+    name: 'Duplicates14',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import {ReentrancyGuardUpgradeable} from '@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol';",
+      "import {ReentrancyGuardUpgradeable as Rguard} from '@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 1,
+    message: ['(globalSamePath) ReentrancyGuardUpgradeable'], // Reentrancy guard duplication with alias
+  },
+  {
+    name: 'Duplicates15',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import './LibraryA.sol';",
+      "import './LibraryA.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 1,
+    message: ['(globalSamePath) LibraryA'], // Same path imported multiple times
+  },
+  {
+    name: 'Duplicates16',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import {Afool, Afool as Fool1, Afool as Fool2, Afool as Fool3} from './Afool.sol';",
+      "import {Afool} from './Afool.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 2,
+    message: ['(inline) Afool', '(globalSamePath) Afool'], // Global duplication within the same path
+  },
+  {
+    name: 'Duplicates17',
+    code: multiLine(
+      '// SPDX-License-Identifier: Apache-2.0',
+      'pragma solidity ^0.8.0;',
+      "import {IXTokenFactory} from '../../token/interfaces/IXTokenFactory2.sol';",
+      "import {IXTokenFactory, IXTokenFactory as AliasFactory} from '../../token/interfaces/IXTokenFactory.sol';",
+      "import '../../token/interfaces/IXTokenFactory.sol';",
+      'contract Test { }'
+    ),
+    qtyDuplicates: 3,
+    message: [
+      '(inline) IXTokenFactory',
+      '(globalSamePath) IXTokenFactory',
+      '(globalDiffPath) IXTokenFactory',
+    ], // Mixed inline and global duplication
+  },
+]
+
+module.exports = { noDuplicates, duplicates }
diff --git a/test/rules/miscellaneous/duplicated-imports.js b/test/rules/miscellaneous/duplicated-imports.js
new file mode 100644
index 0000000..ca8c9c9
--- /dev/null
+++ b/test/rules/miscellaneous/duplicated-imports.js
@@ -0,0 +1,35 @@
+const assert = require('assert')
+const linter = require('../../../lib/index')
+const { assertNoErrors, assertErrorCount } = require('../../common/asserts')
+const TEST_CASES = require('../../fixtures/miscellaneous/duplicated-imports-data')
+
+describe('Linter - duplicated-imports', () => {
+  for (const importCase of TEST_CASES.duplicates) {
+    it(`Should report ${importCase.qtyDuplicates} error/s for ${importCase.name}`, () => {
+      const code = importCase.code
+
+      const report = linter.processStr(code, {
+        rules: { 'duplicated-imports': 'error' },
+      })
+
+      // assert.equal(report.errorCount, )
+      assertErrorCount(report, importCase.qtyDuplicates)
+
+      for (let i = 0; i < report.reports.length; i++) {
+        assert.ok(report.reports[i].message.includes(importCase.message[i]))
+      }
+    })
+  }
+
+  for (const importCase of TEST_CASES.noDuplicates) {
+    it(`Should NOT report errors for ${importCase.name}`, () => {
+      const code = importCase.code
+
+      const report = linter.processStr(code, {
+        rules: { 'duplicated-imports': 'error' },
+      })
+
+      assertNoErrors(report)
+    })
+  }
+})