Repository: protofire/solhint
Pull request #685 (2025-06-26 19:54:13)

feat: added cache to avoid linting files with no changes

Issue
https://github.com/protofire/solhint/issues/661


Patch:
diff --git a/README.md b/README.md
index cf5a649..5a49d15 100644
--- a/README.md
+++ b/README.md
@@ -66,10 +66,13 @@ Options:
   -q, --quiet                             report errors only - default: false
   --ignore-path [file_name]               file to use as your .solhintignore
   --fix                                   automatically fix problems and show report
+  --cache                                 only lint files that changed since last run
+  --cache-location                        path to the cache file
   --noPrompt                              do not suggest to backup files when any `fix` option is selected
   --init                                  create configuration file for solhint
   --disc                                  do not check for solhint updates
   --save                                  save report to file on current folder
+  --noPoster                              remove discord poster
   -h, --help                              output usage information
 
 Commands:
@@ -166,6 +169,25 @@ node_modules/
 additional-tests.sol
 ```
 
+### Cache
+Solhint supports a caching mechanism using the `--cache` flag to avoid re-linting files that haven't changed. 
+When enabled, Solhint stores a hash of each file's content and effective configuration, skipping analysis if neither has changed. 
+By default, the cache is saved in `.solhintcache.json` in the current working directory. 
+You can customize this location using the `--cache-location option`. If no location is specified, the file will be stored in:
+`node_modules/.cache/solhint/.solhint-cache.json`
+
+Warning:
+When using `cache` flag. If a file was analyzed with not error for a certain config, the hash will be stored. If the file is not changed but the config file (`.solhint.json`) has some new rules, the file will not be analyzed. 
+To analyze it again, remove `cache` option.
+
+Example:
+```
+solhint contracts/**/*.sol --cache
+solhint Foo.sol --cache --cache-location tmp/my-cache.json
+```
+
+
+
 ### Extendable rulesets
 
 The rulesets provided by solhint are the following:
diff --git a/lib/cache/cache-manager.js b/lib/cache/cache-manager.js
new file mode 100644
index 0000000..6c7f417
--- /dev/null
+++ b/lib/cache/cache-manager.js
@@ -0,0 +1,48 @@
+const fs = require('fs-extra')
+const crypto = require('crypto')
+const path = require('path')
+
+function getFileHash(content) {
+  return crypto.createHash('sha1').update(content).digest('hex')
+}
+
+function normalizePath(filePath) {
+  return path.relative(process.cwd(), filePath).replace(/\\/g, '/')
+}
+
+function getCacheKey(filePath, config) {
+  const fileKey = normalizePath(filePath)
+  const configHash = getFileHash(JSON.stringify(config.rules || {}))
+  return `${fileKey}::${configHash}`
+}
+
+function readCache(cachePath) {
+  try {
+    return JSON.parse(fs.readFileSync(cachePath, 'utf8'))
+  } catch (_) {
+    return {}
+  }
+}
+
+function writeCache(cachePath, cacheData) {
+  fs.writeFileSync(cachePath, JSON.stringify(cacheData, null, 2))
+}
+
+function shouldLint(filePath, content, config, cacheData) {
+  const key = getCacheKey(filePath, config)
+  const hash = getFileHash(content)
+  return cacheData[key] !== hash
+}
+
+function updateCacheEntry(filePath, content, config, cacheData) {
+  const key = getCacheKey(filePath, config)
+  cacheData[key] = getFileHash(content)
+}
+
+module.exports = {
+  getFileHash,
+  readCache,
+  writeCache,
+  shouldLint,
+  updateCacheEntry,
+}
diff --git a/lib/common/utils.js b/lib/common/utils.js
index fd7bcd1..b75030e 100644
--- a/lib/common/utils.js
+++ b/lib/common/utils.js
@@ -1,4 +1,4 @@
-const fs = require('fs')
+const fs = require('fs-extra')
 const path = require('path')
 
 const getLocFromIndex = (text, index) => {
diff --git a/lib/config/config-file.js b/lib/config/config-file.js
index bd81ade..bda4723 100644
--- a/lib/config/config-file.js
+++ b/lib/config/config-file.js
@@ -1,4 +1,4 @@
-const fs = require('fs')
+const fs = require('fs-extra')
 const path = require('path')
 const _ = require('lodash')
 const { cosmiconfigSync } = require('cosmiconfig')
diff --git a/lib/index.js b/lib/index.js
index 59620b8..d71b4b2 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -1,4 +1,5 @@
-const fs = require('fs')
+const fs = require('fs-extra')
+const path = require('path')
 const parser = require('@solidity-parser/parser')
 const glob = require('glob')
 const ignore = require('ignore')
@@ -7,6 +8,7 @@ const { applyExtends, loadConfigForFile } = require('./config/config-file')
 const Reporter = require('./reporter')
 const TreeListener = require('./tree-listener')
 const checkers = require('./rules/index')
+const { readCache, writeCache, shouldLint, updateCacheEntry } = require('./cache/cache-manager')
 
 function parseInput(inputStr) {
   try {
@@ -51,27 +53,93 @@ function processStr(inputStr, config = {}, fileName = '') {
   return reporter
 }
 
-// Process the solidity file using the specified config or, if not provided,
-// searches for .solhint.json files in the file's directory hierarchy.
-// explicitConfigPath is optional and is used to force a specific config.
+function processAndCache(file, config, cacheState = null) {
+  const useCache = config?.cache
+
+  const code = fs.readFileSync(file, 'utf8')
+
+  if (useCache && cacheState) {
+    const { cacheData, updatedCache } = cacheState
+    if (!shouldLint(file, code, config, cacheData)) {
+      const emptyReport = new Reporter([], config)
+      emptyReport.file = file
+      emptyReport.skipped = true
+      return { report: emptyReport, updatedCache }
+    }
+  }
+
+  const report = processStr(code, config, file)
+  report.file = file
+
+  const hasErrors = report.reports.some((r) => r.severity === 2)
+
+  if (useCache && cacheState && !hasErrors) {
+    updateCacheEntry(file, code, config, cacheState.updatedCache)
+  }
+
+  return { report, updatedCache: cacheState?.updatedCache }
+}
+
 // eslint-disable-next-line default-param-last
 function processFile(file, config, rootDir = process.cwd(), explicitConfigPath) {
-  // If we receive an explicit config, we use it; otherwise, we search hierarchically or use the config passed by -c
   const finalConfig =
     config !== undefined ? config : loadConfigForFile(file, rootDir, explicitConfigPath)
-  const report = processStr(fs.readFileSync(file, 'utf8').toString(), finalConfig, file)
-  report.file = file
+
+  const defaultCachePath = path.join('node_modules', '.cache', 'solhint', '.solhintcache.json')
+  const cachePath = finalConfig.cacheLocation || defaultCachePath
+
+  let cacheState = null
+  if (finalConfig.cache) {
+    const cacheData = readCache(cachePath)
+    cacheState = {
+      cacheData,
+      updatedCache: { ...cacheData },
+    }
+  }
+
+  const { report, updatedCache } = processAndCache(file, finalConfig, cacheState)
+
+  if (finalConfig.cache && updatedCache) {
+    fs.ensureDirSync(path.dirname(cachePath)) // make sure the directory exists
+    writeCache(cachePath, updatedCache)
+  }
+
   return report
 }
 
 // eslint-disable-next-line default-param-last
 function processPath(pattern, config, rootDir = process.cwd(), explicitConfigPath) {
-  const ignoreFilter = ignore({ allowRelativePaths: true }).add(
-    (config && config.excludedFiles) || []
-  )
+  const ignoreFilter = ignore({ allowRelativePaths: true }).add(config?.excludedFiles || [])
   const allFiles = glob.sync(pattern, { nodir: true })
   const files = ignoreFilter.filter(allFiles)
-  return files.map((curFile) => processFile(curFile, config, rootDir, explicitConfigPath))
+
+  const useCache = config?.cache
+  const defaultCachePath = path.join('node_modules', '.cache', 'solhint', '.solhintcache.json')
+  const cachePath = config?.cacheLocation || defaultCachePath
+
+  const cacheData = useCache ? readCache(cachePath) : {}
+  const updatedCache = { ...cacheData }
+
+  const reports = []
+
+  for (const file of files) {
+    const fileConfig =
+      config !== undefined ? config : loadConfigForFile(file, rootDir, explicitConfigPath)
+
+    const { report } = processAndCache(file, fileConfig, {
+      cacheData,
+      updatedCache,
+    })
+
+    reports.push(report)
+  }
+
+  if (useCache) {
+    fs.ensureDirSync(path.dirname(cachePath)) // make sure the directory exists
+    writeCache(cachePath, updatedCache)
+  }
+
+  return reports
 }
 
 module.exports = { processPath, processFile, processStr }
diff --git a/lib/rules/miscellaneous/import-path-check.js b/lib/rules/miscellaneous/import-path-check.js
index 8a284db..92519ca 100644
--- a/lib/rules/miscellaneous/import-path-check.js
+++ b/lib/rules/miscellaneous/import-path-check.js
@@ -1,4 +1,4 @@
-const fs = require('fs')
+const fs = require('fs-extra')
 const path = require('path')
 const BaseChecker = require('../base-checker')
 const { severityDescription } = require('../../doc/utils')
diff --git a/package.json b/package.json
index 55b66d9..60ae329 100644
--- a/package.json
+++ b/package.json
@@ -60,7 +60,8 @@
     "semver": "^7.5.2",
     "strip-ansi": "^6.0.1",
     "table": "^6.8.1",
-    "text-table": "^0.2.0"
+    "text-table": "^0.2.0",
+    "fs-extra": "^11.1.0"
   },
   "devDependencies": {
     "assert": "^2.0.0",
@@ -71,7 +72,6 @@
     "eslint-config-prettier": "^8.6.0",
     "eslint-plugin-import": "^2.29.1",
     "eslint-plugin-prettier": "^4.2.1",
-    "fs-extra": "^11.1.0",
     "get-stream": "^6.0.0",
     "markdown-table": "^2.0.0",
     "mocha": "^10.2.0",
diff --git a/scripts/generate-rule-docs.js b/scripts/generate-rule-docs.js
index f858024..5501c21 100755
--- a/scripts/generate-rule-docs.js
+++ b/scripts/generate-rule-docs.js
@@ -1,5 +1,5 @@
 #!env node
-const fs = require('fs')
+const fs = require('fs-extra')
 const { exec, mkdir } = require('shelljs')
 const semver = require('semver')
 const path = require('path')
diff --git a/scripts/generate-rulesets.js b/scripts/generate-rulesets.js
index 0696be2..816c9cf 100644
--- a/scripts/generate-rulesets.js
+++ b/scripts/generate-rulesets.js
@@ -1,4 +1,4 @@
-const fs = require('fs')
+const fs = require('fs-extra')
 
 const { loadRules } = require('../lib/load-rules')
 
diff --git a/solhint.js b/solhint.js
index fe7299b..30c38b5 100755
--- a/solhint.js
+++ b/solhint.js
@@ -1,7 +1,7 @@
 #!/usr/bin/env node
 const program = require('commander')
 const _ = require('lodash')
-const fs = require('fs')
+const fs = require('fs-extra')
 const process = require('process')
 const readline = require('readline')
 const chalk = require('chalk')
@@ -31,6 +31,8 @@ function init() {
     .option('-q, --quiet', 'report errors only - default: false')
     .option('--ignore-path [file_name]', 'file to use as your .solhintignore')
     .option('--fix', 'automatically fix problems. Skips fixes in report')
+    .option('--cache', 'Only lint files that changed since last run')
+    .option('--cache-location [file_name]', 'Path to the cache file')
     .option('--noPrompt', 'do not suggest to backup files when any `fix` option is selected')
     .option('--init', 'create configuration file for solhint')
     .option('--disc', 'do not check for solhint updates')
@@ -191,12 +193,19 @@ function executeMainActionLogic() {
     // explicit config is used if the user passed -c or --config
     reports = filesToLint.map((file) => {
       const configForFile = loadConfigForFile(file, process.cwd(), program.opts().config)
+
       validate(configForFile)
+
       return require('./lib/index').processFile(
         file,
-        configForFile,
+        {
+          ...configForFile,
+          fix: program.opts().fix,
+          cache: program.opts().cache,
+          cacheLocation: program.opts().cacheLocation,
+        },
         process.cwd(),
-        program.opts().config // explicitConfigPath al final
+        program.opts().config
       )
     })
   } catch (e) {


Test patch:
diff --git a/e2e/12-cache-support/.solhint.json b/e2e/12-cache-support/.solhint.json
new file mode 100644
index 0000000..8f0fab1
--- /dev/null
+++ b/e2e/12-cache-support/.solhint.json
@@ -0,0 +1,5 @@
+{
+  "rules": {
+    "compiler-version": ["error", "^0.8.24"]
+  }
+}
diff --git a/e2e/12-cache-support/Foo.sol b/e2e/12-cache-support/Foo.sol
new file mode 100644
index 0000000..2cfcfce
--- /dev/null
+++ b/e2e/12-cache-support/Foo.sol
@@ -0,0 +1,9 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+contract Foo {
+    constructor() {
+        uint256 aa = 1;
+        aa;
+    }
+}
diff --git a/e2e/12-cache-support/FooError.sol b/e2e/12-cache-support/FooError.sol
new file mode 100644
index 0000000..8785b80
--- /dev/null
+++ b/e2e/12-cache-support/FooError.sol
@@ -0,0 +1,9 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.7.0;
+
+contract Foo {
+    constructor() {
+        uint256 aa = 1;
+        aa;
+    }
+}
diff --git a/e2e/12-cache-support/FooValid.sol b/e2e/12-cache-support/FooValid.sol
new file mode 100644
index 0000000..4232a22
--- /dev/null
+++ b/e2e/12-cache-support/FooValid.sol
@@ -0,0 +1,9 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+contract Foo {
+    constructor() {
+        uint256 bb = 1;
+        bb;
+    }
+}
diff --git a/e2e/test.js b/e2e/test.js
index aaf112b..a87f412 100644
--- a/e2e/test.js
+++ b/e2e/test.js
@@ -274,6 +274,81 @@ describe('e2e general tests', function () {
       expect(stdout.trim()).to.not.contain(ERROR_QUOTES)
     })
   })
+
+  describe('cache support', () => {
+    const PATH = '12-cache-support'
+    let cacheFilePath
+
+    useFixture(PATH)
+
+    beforeEach(() => {
+      cacheFilePath = path.join('node_modules', '.cache', 'solhint', '.solhintcache.json')
+      if (fs.existsSync(cacheFilePath)) fs.unlinkSync(cacheFilePath)
+    })
+
+    it('should create cache file and lint normally on first run', () => {
+      const { code, stdout } = shell.exec(`solhint Foo.sol --cache --noPoster`)
+      expect(fs.existsSync(cacheFilePath)).to.be.true
+
+      const cache = JSON.parse(fs.readFileSync(cacheFilePath, 'utf8'))
+
+      expect(stdout.trim()).to.equal('')
+      expect(code).to.equal(EXIT_CODES.OK)
+      expect(Object.keys(cache).length).to.equal(1)
+    })
+
+    it('should skip linting on second run if file unchanged', () => {
+      shell.exec(`solhint Foo.sol --cache --noPoster`) // populate cache
+
+      const cacheBefore = JSON.parse(fs.readFileSync(cacheFilePath, 'utf8'))
+
+      const { code, stdout } = shell.exec(`solhint Foo.sol --cache --noPoster`)
+
+      const cacheAfter = JSON.parse(fs.readFileSync(cacheFilePath, 'utf8'))
+
+      expect(code).to.equal(EXIT_CODES.OK)
+      expect(stdout.trim()).to.equal('')
+      expect(cacheAfter).to.deep.equal(cacheBefore)
+    })
+
+    it('should re-lint if file content changes', () => {
+      let code
+      let stdout
+      // first run no error, populate cache
+      shell.exec(`solhint Foo.sol --cache --noPoster`) // first run
+
+      // replace file with an error one
+      const cacheBefore = JSON.parse(fs.readFileSync(cacheFilePath, 'utf8'))
+      fs.copyFileSync('FooError.sol', 'Foo.sol') // overwrite Foo.sol
+
+      // check cache was not updated yet
+      const cacheMid = JSON.parse(fs.readFileSync(cacheFilePath, 'utf8'))
+      expect(cacheMid).to.deep.equal(cacheBefore, 'error cache mid')
+
+      // cache will not be updated because now Foo.sol has an error
+      let result = shell.exec(`solhint Foo.sol --cache --noPoster`)
+      code = result.code
+      stdout = result.stdout
+      const cacheAfter1 = JSON.parse(fs.readFileSync(cacheFilePath, 'utf8'))
+
+      // assert the errors
+      expect(code).to.equal(EXIT_CODES.REPORTED_ERRORS)
+      expect(stdout.trim()).to.contain('Compiler version')
+      expect(cacheAfter1).to.deep.equal(cacheBefore, "error cacheAfter1")
+      
+      // now replace Foo.sol with a valid file
+      fs.copyFileSync('FooValid.sol','Foo.sol') // restore Foo.sol
+      result = shell.exec(`solhint Foo.sol --cache --noPoster`)
+      code = result.code
+      stdout = result.stdout
+      const cacheAfter2 = JSON.parse(fs.readFileSync(cacheFilePath, 'utf8'))
+
+      // expect no errors and cache to be updated
+      expect(code).to.equal(EXIT_CODES.OK, "")
+      expect(stdout.trim()).to.equal('')
+      expect(cacheAfter2).to.not.deep.equal(cacheBefore, "error cacheAfter2")
+    })
+  })
 })
 
 function useFixture(dir) {
diff --git a/test/common/utils.js b/test/common/utils.js
index 02aa468..0bcc19a 100644
--- a/test/common/utils.js
+++ b/test/common/utils.js
@@ -1,4 +1,4 @@
-const fs = require('fs')
+const fs = require('fs-extra')
 const os = require('os')
 const path = require('path')
 
diff --git a/test/rules/miscellaneous/config-hierarchy.js b/test/rules/miscellaneous/config-hierarchy.js
index 37fd7b4..688904a 100644
--- a/test/rules/miscellaneous/config-hierarchy.js
+++ b/test/rules/miscellaneous/config-hierarchy.js
@@ -1,4 +1,4 @@
-const fs = require('fs')
+const fs = require('fs-extra')
 const path = require('path')
 const os = require('os')
 const rimraf = require('rimraf')
diff --git a/test/rules/miscellaneous/import-path-check.js b/test/rules/miscellaneous/import-path-check.js
index 7491508..1647998 100644
--- a/test/rules/miscellaneous/import-path-check.js
+++ b/test/rules/miscellaneous/import-path-check.js
@@ -17,7 +17,7 @@ describe('import-path-check (mocked fs)', () => {
   }
 
   beforeEach(() => {
-    existsStub = sinon.stub(require('fs'), 'existsSync').callsFake((filePath) => {
+    existsStub = sinon.stub(require('fs-extra'), 'existsSync').callsFake((filePath) => {
       return currentFakeFileSystem.has(normalizePath(filePath))
     })
   })