From 46146a7676a980cd191b445f8034fb1fecb2bc39 Mon Sep 17 00:00:00 2001 From: dbale-altoros Date: Wed, 21 Jan 2026 17:32:11 -0300 Subject: [PATCH 01/21] fix: minor issues --- lib/rules/best-practices/no-console.js | 4 - test/common/skip-rules-comments.js | 146 +++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 test/common/skip-rules-comments.js diff --git a/lib/rules/best-practices/no-console.js b/lib/rules/best-practices/no-console.js index da5a5801..56aeab92 100644 --- a/lib/rules/best-practices/no-console.js +++ b/lib/rules/best-practices/no-console.js @@ -76,10 +76,6 @@ class NoConsoleLogChecker extends BaseChecker { // to remove the ';' if (type === 'call') range[1] += 1 - // // to remove the ';' and the 'enter' - // if (type === 'call') range[1] += 2 - // else range[1] += 1 // to remove the 'enter' - return (fixer) => fixer.removeRange(range) } } diff --git a/test/common/skip-rules-comments.js b/test/common/skip-rules-comments.js new file mode 100644 index 00000000..044d168d --- /dev/null +++ b/test/common/skip-rules-comments.js @@ -0,0 +1,146 @@ +/* eslint-disable no-empty */ +const assert = require('assert') + +const linter = require('../../lib/index') +const { funcWith } = require('./contract-builder') + +const { + assertErrorCount, + assertNoErrors, + assertNoWarnings, + assertErrorMessage, +} = require('./asserts') + +const DIRECTIVE = 'solhint' + +// Messages (match your rule implementations) +const msgNoConsole = `Unexpected console statement` +const msgReasonString = `Provide an error message` + +describe('Configure the linter with comments (solhint-style directives)', () => { + it('1) should support: // solhint-disable-next-line (disable ALL rules on next line)', () => { + const code = funcWith(` + // ${DIRECTIVE}-disable-next-line + console.log("hi"); +`) + + const report = linter.processStr(code, { + rules: { + 'no-console': 'error', + }, + }) + + assertNoErrors(report) + assertNoWarnings(report) + }) + + it('2) should support: // solhint-disable-next-line (disable specific rules on next line)', () => { + const code = funcWith(` + // ${DIRECTIVE}-disable-next-line no-console + console.log("hi"); + + // no comment here => should still be reported + require(a > b); +`) + + const report = linter.processStr(code, { + rules: { + 'no-console': 'error', + 'reason-string': 'error', + }, + }) + + assertErrorCount(report, 1) + assertErrorMessage(report, msgReasonString) + }) + + it('3) should support: // solhint-disable-line (disable ALL rules on current line)', () => { + const code = funcWith(` + console.log("hi"); // ${DIRECTIVE}-disable-line +`) + + const report = linter.processStr(code, { + rules: { + 'no-console': 'error', + }, + }) + + assertNoErrors(report) + assertNoWarnings(report) + }) + + it('4) should support: // solhint-disable-line (disable specific rules on current line)', () => { + const code = funcWith(` + console.log("hi"); require(a > b); // ${DIRECTIVE}-disable-line no-console +`) + + const report = linter.processStr(code, { + rules: { + 'no-console': 'error', + 'reason-string': 'error', + }, + }) + + // console suppressed, reason string reported + assertErrorCount(report, 1) + assertErrorMessage(report, msgReasonString) + assert.ok( + !report.messages + .map((m) => m.message) + .join('\n') + .includes(msgNoConsole), + 'Did not expect no-console to be reported on disabled line' + ) + }) + + it('5) should support: /* solhint-disable */ ... /* solhint-enable */ (block disable one rule)', () => { + const code = funcWith(` + require(a > b); // should be reported + + /* ${DIRECTIVE}-disable no-console */ + console.log("hi"); // should be suppressed + require(c > d); // should be reported again + /* ${DIRECTIVE}-enable no-console */ + + console.log("hi"); // should be reported again +`) + + const report = linter.processStr(code, { + rules: { + 'no-console': 'error', + 'reason-string': 'error', + }, + }) + + // require first, require in the disabled block, console log after re-enable + assertErrorCount(report, 3) + assert.ok(report.reports[0].message.includes(msgReasonString)) + assert.ok(report.reports[1].message.includes(msgReasonString)) + assert.ok(report.reports[2].message.includes(msgNoConsole)) + }) + + it('6) should support: /* solhint-disable */ ... /* solhint-enable */ (block disable ALL rules)', () => { + const code = funcWith(` + require(a > b); // should be reported + + /* ${DIRECTIVE}-disable */ + console.log("hi"); // should be suppressed + require(c > d); // should be reported again + /* ${DIRECTIVE}-enable */ + + console.log("hi"); // should be reported again +`) + + const report = linter.processStr(code, { + rules: { + 'no-console': 'error', + 'reason-string': 'error', + }, + }) + + // Only the println after re-enable + assertErrorCount(report, 2) + assert.ok(report.reports[0].message.includes(msgReasonString)) + assert.ok(report.reports[1].message.includes(msgNoConsole)) + }) +}) From d61bb5a76c78a46247fb0eda097cd2eaf461cf7a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:32:22 +0000 Subject: [PATCH 02/21] chore(deps): bump lodash from 4.17.21 to 4.17.23 Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23. - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23) --- updated-dependencies: - dependency-name: lodash dependency-version: 4.17.23 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 96a72aa6..79cb3a39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "solhint", - "version": "6.0.2", + "version": "6.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solhint", - "version": "6.0.2", + "version": "6.0.3", "license": "MIT", "dependencies": { "@solidity-parser/parser": "^0.20.2", @@ -4119,9 +4119,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.flattendeep": { From 824f13c1f45122859e960119d0afb926320b2c6b Mon Sep 17 00:00:00 2001 From: dbale-altoros Date: Fri, 23 Jan 2026 14:03:21 -0300 Subject: [PATCH 03/21] fix: scoped packs for shareable configs --- docs/shareable-configs.md | 107 +++++++++++++++++- .../filesystem01/project/.solhint.json | 3 + .../project/contracts/EmptyBlocks.sol | 6 + .../filesystem02/project/.solhint.json | 3 + .../project/contracts/.solhint.json | 6 + .../filesystem02/project/contracts/A.sol | 4 + .../filesystem03/project/.solhint.json | 3 + .../project/contracts/deep/.solhint.json | 6 + .../filesystem03/project/contracts/deep/B.sol | 5 + .../filesystem04/project/.solhint.json | 4 + .../project/contracts/EmptyBlocks.sol | 6 + .../filesystem05/project/.solhint.json | 5 + .../project/contracts/EmptyBlocks.sol | 6 + .../filesystem06/project/.solhint.json | 4 + .../project/contracts/EmptyBlocks.sol | 6 + .../filesystem07/project/.solhint.json | 4 + .../project/contracts/EmptyBlocks.sol | 6 + .../filesystem08/project/.solhint.json | 4 + .../project/contracts/EmptyBlocks.sol | 6 + e2e/test.js | 93 +++++++++++++-- lib/config/config-file.js | 89 ++++++++++++++- test/common/apply-extends.js | 55 +++++++++ 22 files changed, 410 insertions(+), 21 deletions(-) create mode 100644 e2e/14-shareable-config/filesystem01/project/.solhint.json create mode 100644 e2e/14-shareable-config/filesystem01/project/contracts/EmptyBlocks.sol create mode 100644 e2e/14-shareable-config/filesystem02/project/.solhint.json create mode 100644 e2e/14-shareable-config/filesystem02/project/contracts/.solhint.json create mode 100644 e2e/14-shareable-config/filesystem02/project/contracts/A.sol create mode 100644 e2e/14-shareable-config/filesystem03/project/.solhint.json create mode 100644 e2e/14-shareable-config/filesystem03/project/contracts/deep/.solhint.json create mode 100644 e2e/14-shareable-config/filesystem03/project/contracts/deep/B.sol create mode 100644 e2e/14-shareable-config/filesystem04/project/.solhint.json create mode 100644 e2e/14-shareable-config/filesystem04/project/contracts/EmptyBlocks.sol create mode 100644 e2e/14-shareable-config/filesystem05/project/.solhint.json create mode 100644 e2e/14-shareable-config/filesystem05/project/contracts/EmptyBlocks.sol create mode 100644 e2e/14-shareable-config/filesystem06/project/.solhint.json create mode 100644 e2e/14-shareable-config/filesystem06/project/contracts/EmptyBlocks.sol create mode 100644 e2e/14-shareable-config/filesystem07/project/.solhint.json create mode 100644 e2e/14-shareable-config/filesystem07/project/contracts/EmptyBlocks.sol create mode 100644 e2e/14-shareable-config/filesystem08/project/.solhint.json create mode 100644 e2e/14-shareable-config/filesystem08/project/contracts/EmptyBlocks.sol diff --git a/docs/shareable-configs.md b/docs/shareable-configs.md index 693ab917..c6bf4996 100644 --- a/docs/shareable-configs.md +++ b/docs/shareable-configs.md @@ -4,22 +4,99 @@ Shareable configs are configurations that you can use and extend from. They can To use a shareable config, you have to add it to your Solhint configuration: -``` +```json +{ "extends": ["solhint:recommended", "protofire"] +} +``` + +This example shows the two types of shareable configs that you can use: + +- **Built-in Solhint configs**, which start with `solhint:` (e.g. `solhint:recommended`, `solhint:all`) +- **Shareable configs installed from npm** + + +## Using npm shareable configs + +Unscoped shareable configs are npm packages prefixed with `solhint-config-`. + +For example, this configuration: + +```json +{ + "extends": ["protofire"] +} +``` + +Will load the package: + +``` +solhint-config-protofire +``` + +which must be installed beforehand: + +``` +npm install solhint-config-protofire +``` + +You can also reference the full package name explicitly: + +```json +{ + "extends": ["solhint-config-protofire"] +} +``` + +Shareable configs are resolved from the project's `node_modules` directory (the current working directory), even when Solhint is installed globally. + + +## Scoped shareable configs + +Solhint also supports **scoped** shareable configs. + +Given the npm package: + +``` +@scope/solhint-config-myconfig +``` + +You can use it in your configuration as: + +```json +{ + "extends": ["@scope/solhint-config-myconfig"] +} +``` + +For convenience, Solhint also supports the ESLint-style shorthand: + +```json +{ + "extends": ["@scope/myconfig"] +} ``` -This example shows the two types of shareable configs that you can use: the ones included with Solhint, that start with `solhint:`, and the ones that you can install from npm. The latter are packages that are prefixed with `solhint-config-`, so in this case the package would be installed doing `npm install solhint-config-protofire` but used as just `protofire` when adding it. +Which resolves to: + +``` +@scope/solhint-config-myconfig +``` + +Note: Only package-level scoped configs are supported (`@scope/name`). Deep paths such as `@scope/name/extra` are not supported. + ## Creating your own shareable config Shareable configs are regular npm packages. There are only two conditions: -- The name of the package must start with `solhint-config-` -- The package must export a regular object with the same structure as a regular configuration object (i.e. the one in your `.solhint.json`). +- The package name must start with `solhint-config-` + (for scoped packages: `@scope/solhint-config-*`) +- The package must export a regular object with the same structure as a standard Solhint configuration (i.e. the one in your `.solhint.json`) For example, a very minimal `index.js` in this package could be: -```javascript +```js module.exports = { rules: { 'max-line-length': 80 @@ -27,4 +104,22 @@ module.exports = { } ``` -After creating this package you can publish it to npm to make it available for everyone. +After creating this package, you can publish it to npm to make it available for everyone. + + +## Configuration inheritance + +Solhint supports **hierarchical configuration**. + +When linting a file, configurations are merged in the following order: + +1. The project root configuration +2. Each parent directory configuration +3. The directory containing the file (**highest precedence**) + +For each configuration file, any `extends` entries are resolved first, and then all configurations are merged according to directory hierarchy. + +Rules and settings defined in deeper directories **override** those from higher-level directories and from extended configs. + +In short: +**`extends` are resolved first, directory hierarchy is applied after, and the closest config always wins.** diff --git a/e2e/14-shareable-config/filesystem01/project/.solhint.json b/e2e/14-shareable-config/filesystem01/project/.solhint.json new file mode 100644 index 00000000..b1114f6f --- /dev/null +++ b/e2e/14-shareable-config/filesystem01/project/.solhint.json @@ -0,0 +1,3 @@ +{ + "extends": ["@test/demo"] +} diff --git a/e2e/14-shareable-config/filesystem01/project/contracts/EmptyBlocks.sol b/e2e/14-shareable-config/filesystem01/project/contracts/EmptyBlocks.sol new file mode 100644 index 00000000..f8bce5d1 --- /dev/null +++ b/e2e/14-shareable-config/filesystem01/project/contracts/EmptyBlocks.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract A { + function f() public {} +} diff --git a/e2e/14-shareable-config/filesystem02/project/.solhint.json b/e2e/14-shareable-config/filesystem02/project/.solhint.json new file mode 100644 index 00000000..3061ed16 --- /dev/null +++ b/e2e/14-shareable-config/filesystem02/project/.solhint.json @@ -0,0 +1,3 @@ +{ + "extends": ["@test/base"] +} diff --git a/e2e/14-shareable-config/filesystem02/project/contracts/.solhint.json b/e2e/14-shareable-config/filesystem02/project/contracts/.solhint.json new file mode 100644 index 00000000..79f879b7 --- /dev/null +++ b/e2e/14-shareable-config/filesystem02/project/contracts/.solhint.json @@ -0,0 +1,6 @@ +{ + "rules": { + "max-line-length": ["error", 80] + } +} + diff --git a/e2e/14-shareable-config/filesystem02/project/contracts/A.sol b/e2e/14-shareable-config/filesystem02/project/contracts/A.sol new file mode 100644 index 00000000..e9ac7925 --- /dev/null +++ b/e2e/14-shareable-config/filesystem02/project/contracts/A.sol @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract A { function f() public { uint x = 1; uint y = 2; uint z = 3; uint w = 4; } function g() public { } } diff --git a/e2e/14-shareable-config/filesystem03/project/.solhint.json b/e2e/14-shareable-config/filesystem03/project/.solhint.json new file mode 100644 index 00000000..3061ed16 --- /dev/null +++ b/e2e/14-shareable-config/filesystem03/project/.solhint.json @@ -0,0 +1,3 @@ +{ + "extends": ["@test/base"] +} diff --git a/e2e/14-shareable-config/filesystem03/project/contracts/deep/.solhint.json b/e2e/14-shareable-config/filesystem03/project/contracts/deep/.solhint.json new file mode 100644 index 00000000..9c999260 --- /dev/null +++ b/e2e/14-shareable-config/filesystem03/project/contracts/deep/.solhint.json @@ -0,0 +1,6 @@ +{ + "rules": { + "max-line-length": ["error", 60], + "no-empty-blocks": "off" + } +} diff --git a/e2e/14-shareable-config/filesystem03/project/contracts/deep/B.sol b/e2e/14-shareable-config/filesystem03/project/contracts/deep/B.sol new file mode 100644 index 00000000..11f37c26 --- /dev/null +++ b/e2e/14-shareable-config/filesystem03/project/contracts/deep/B.sol @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract B { function g() public { uint x = 1; uint y = 2; uint z = 3; uint w = 4; } function g() public { }} + diff --git a/e2e/14-shareable-config/filesystem04/project/.solhint.json b/e2e/14-shareable-config/filesystem04/project/.solhint.json new file mode 100644 index 00000000..3bda7b92 --- /dev/null +++ b/e2e/14-shareable-config/filesystem04/project/.solhint.json @@ -0,0 +1,4 @@ +{ + "extends": ["a", "b"] +} + diff --git a/e2e/14-shareable-config/filesystem04/project/contracts/EmptyBlocks.sol b/e2e/14-shareable-config/filesystem04/project/contracts/EmptyBlocks.sol new file mode 100644 index 00000000..4a07940b --- /dev/null +++ b/e2e/14-shareable-config/filesystem04/project/contracts/EmptyBlocks.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract C { function f() public { } } + + diff --git a/e2e/14-shareable-config/filesystem05/project/.solhint.json b/e2e/14-shareable-config/filesystem05/project/.solhint.json new file mode 100644 index 00000000..408f382b --- /dev/null +++ b/e2e/14-shareable-config/filesystem05/project/.solhint.json @@ -0,0 +1,5 @@ +{ + "extends": ["a", "b"], + "rules": { "no-empty-blocks": "off" } +} + diff --git a/e2e/14-shareable-config/filesystem05/project/contracts/EmptyBlocks.sol b/e2e/14-shareable-config/filesystem05/project/contracts/EmptyBlocks.sol new file mode 100644 index 00000000..4a07940b --- /dev/null +++ b/e2e/14-shareable-config/filesystem05/project/contracts/EmptyBlocks.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract C { function f() public { } } + + diff --git a/e2e/14-shareable-config/filesystem06/project/.solhint.json b/e2e/14-shareable-config/filesystem06/project/.solhint.json new file mode 100644 index 00000000..4a7dcb45 --- /dev/null +++ b/e2e/14-shareable-config/filesystem06/project/.solhint.json @@ -0,0 +1,4 @@ +{ + "extends": ["@test/solhint-config-demo"] +} + diff --git a/e2e/14-shareable-config/filesystem06/project/contracts/EmptyBlocks.sol b/e2e/14-shareable-config/filesystem06/project/contracts/EmptyBlocks.sol new file mode 100644 index 00000000..4a07940b --- /dev/null +++ b/e2e/14-shareable-config/filesystem06/project/contracts/EmptyBlocks.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract C { function f() public { } } + + diff --git a/e2e/14-shareable-config/filesystem07/project/.solhint.json b/e2e/14-shareable-config/filesystem07/project/.solhint.json new file mode 100644 index 00000000..ff64a682 --- /dev/null +++ b/e2e/14-shareable-config/filesystem07/project/.solhint.json @@ -0,0 +1,4 @@ +{ + "extends": ["solhint-config-demo"] +} + diff --git a/e2e/14-shareable-config/filesystem07/project/contracts/EmptyBlocks.sol b/e2e/14-shareable-config/filesystem07/project/contracts/EmptyBlocks.sol new file mode 100644 index 00000000..4a07940b --- /dev/null +++ b/e2e/14-shareable-config/filesystem07/project/contracts/EmptyBlocks.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract C { function f() public { } } + + diff --git a/e2e/14-shareable-config/filesystem08/project/.solhint.json b/e2e/14-shareable-config/filesystem08/project/.solhint.json new file mode 100644 index 00000000..06f01aec --- /dev/null +++ b/e2e/14-shareable-config/filesystem08/project/.solhint.json @@ -0,0 +1,4 @@ +{ + "extends": ["@test/demo/extra"] +} + diff --git a/e2e/14-shareable-config/filesystem08/project/contracts/EmptyBlocks.sol b/e2e/14-shareable-config/filesystem08/project/contracts/EmptyBlocks.sol new file mode 100644 index 00000000..4a07940b --- /dev/null +++ b/e2e/14-shareable-config/filesystem08/project/contracts/EmptyBlocks.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract C { function f() public { } } + + diff --git a/e2e/test.js b/e2e/test.js index f923e235..8590a4e8 100644 --- a/e2e/test.js +++ b/e2e/test.js @@ -239,12 +239,12 @@ describe('e2e general tests', function () { it('Should fail when missing import (relative path) - filesystem04', () => { const { code, stdout } = shell.exec( - `solhint --noPoster -c ".solhintF04.json" "./contracts/Test.sol"` + `solhint --noPoster -c ".solhintF04.json" "./contracts/Test.sol"` ) - + expect(code).to.equal(EXIT_CODES.REPORTED_ERRORS) - const expectedPath = path.join('contracts', 'Test.sol') + const expectedPath = path.join('contracts', 'Test.sol') expect(stdout).to.include(`Import in ${expectedPath} doesn't exist in: ./Missing.sol`) }) }) @@ -440,7 +440,7 @@ describe('e2e general tests', function () { expect(stdout.trim()).to.not.contain(ERROR_EMPTY_BLOCKS) }) - it('should - filesystem06', () => { + it('should fail on invalid path - filesystem06', () => { const { code, stderr } = shell.exec( `solhint --noPoster --ignore-path ".wrongFile" -c ".solhintS06.json" "contracts/**/*.sol"` ) @@ -462,6 +462,81 @@ describe('e2e general tests', function () { expect(stdout.trim()).to.not.contain('Skip.sol') }) }) + + describe('shareable configs', function () { + const PATH = '14-shareable-config/filesystem' + let folderCounter = 1 + + beforeEach(() => { + const padded = String(folderCounter).padStart(2, '0') + + const ROOT = PATH + padded + '/' + useFixtureFolder(this, ROOT + 'project') + + folderCounter++ + }) + + it('should load scoped shareable config via extends - fs1', () => { + const { code, stdout } = shell.exec(`solhint contracts/EmptyBlocks.sol`) + + expect(code).to.equal(EXIT_CODES.REPORTED_ERRORS) + expect(stdout).to.include('no-empty-blocks') + }) + + it('should report when hierarchical config + extends (deepest directory with highest precedence 1) - fs2', () => { + // applies deepest directory config with highest precedence (A.sol uses contracts/.solhint.json) + const { code, stdout } = shell.exec(`solhint contracts/A.sol`) + expect(code).to.equal(EXIT_CODES.REPORTED_ERRORS) + expect(stdout).to.include('max-line-length') + expect(stdout).to.include('no-empty-blocks') + }) + + it('should report when hierarchical config + extends (deepest directory with highest precedence 2) - fs3', () => { + // applies deepest directory config with highest precedence (B.sol uses contracts/deep/.solhint.json) + const { code, stdout } = shell.exec(`solhint contracts/deep/B.sol`) + + expect(code).to.equal(EXIT_CODES.REPORTED_ERRORS) + expect(stdout).to.include('max-line-length') + expect(stdout).to.not.include('no-empty-blocks') + }) + + it('should validate later extends has higher priority (a then b => error)', () => { + const { code, stdout } = shell.exec(`solhint contracts/EmptyBlocks.sol - fs4`) + + expect(code).to.equal(EXIT_CODES.REPORTED_ERRORS) + expect(stdout).to.include('no-empty-blocks') + }) + + it('should override local to contract config over extends in node modules', () => { + const { code, stdout } = shell.exec(`solhint contracts/EmptyBlocks.sol - fs5`) + + expect(code).to.equal(EXIT_CODES.OK) + expect(stdout).to.not.include('no-empty-blocks') + }) + + it('should load scoped shareable config via full package name (@scope/solhint-config-*) - fs6', () => { + const { code, stdout } = shell.exec(`solhint contracts/EmptyBlocks.sol`) + + // because it is configured as warning in the shareable config + expect(code).to.equal(EXIT_CODES.OK) + expect(stdout).to.include('no-empty-blocks') + expect(stdout).to.include('warning') + }) + + it('should accept explicit unscoped full package name (no double-prefix) - fs7', () => { + const { code, stdout } = shell.exec(`solhint contracts/EmptyBlocks.sol`) + + expect(code).to.equal(EXIT_CODES.REPORTED_ERRORS) + expect(stdout).to.include('no-empty-blocks') + }) + + it('should reject malformed scoped extends (@scope/name/extra) - fs8', () => { + const { code, stdout, stderr } = shell.exec(`solhint contracts/EmptyBlocks.sol`) + + expect(code).to.equal(EXIT_CODES.BAD_OPTIONS) + expect(stderr + stdout).to.include('Failed to load config "@test/demo/extra"') + }) + }) }) function useFixture(dir) { @@ -478,12 +553,12 @@ function useFixtureFolder(ctx, dir) { ctx.testDirPath = testDirPath - fs.mkdirSync(testDirPath, { recursive: true }) - for (const entry of fs.readdirSync(testDirPath)) { - fs.rmSync(path.join(testDirPath, entry), { recursive: true, force: true }); - } + fs.mkdirSync(testDirPath, { recursive: true }) + for (const entry of fs.readdirSync(testDirPath)) { + fs.rmSync(path.join(testDirPath, entry), { recursive: true, force: true }) + } - fs.cpSync(fixturePath, testDirPath, { recursive: true }) + fs.cpSync(fixturePath, testDirPath, { recursive: true }) shell.cd(testDirPath) } diff --git a/lib/config/config-file.js b/lib/config/config-file.js index bd81ade8..04a42175 100644 --- a/lib/config/config-file.js +++ b/lib/config/config-file.js @@ -1,10 +1,23 @@ +// lib/config/config-file.js + const fs = require('fs') const path = require('path') const _ = require('lodash') const { cosmiconfigSync } = require('cosmiconfig') +const { createRequire } = require('module') // Resolve shareable configs from the project (cwd) const { ConfigMissingError } = require('../common/errors') const packageJson = require('../../package.json') +// ----------------------------------------------------------------------------- +// Constants (keep behavior, improve readability) +// ----------------------------------------------------------------------------- +const CORE_PREFIX = 'solhint:' +const UNSCOPED_SHAREABLE_PREFIX = 'solhint-config-' +const REQUIRE_ANCHOR_BASENAME = '__solhint_require_anchor__.js' + +// ----------------------------------------------------------------------------- +// Core presets +// ----------------------------------------------------------------------------- const getSolhintCoreConfig = (name) => { if (name === 'solhint:recommended') { return require('../../conf/rulesets/solhint-recommended') @@ -59,13 +72,76 @@ const loadConfig = (configFile) => { } const isAbsolute = path.isAbsolute -const configGetter = (path) => { - if (isAbsolute(path)) { - return require(path) + +function resolveShareableConfigName(extendsValue) { + // core presets + if (extendsValue.startsWith(CORE_PREFIX)) return extendsValue + + // absolute path stays as-is + if (isAbsolute(extendsValue)) return extendsValue + + // scoped packages: @scope/name + if (extendsValue.startsWith('@')) { + const parts = extendsValue.split('/') + + // Require-compatible scoped package must be exactly "@scope/name" + // (we deliberately do NOT support deeper paths like "@scope/name/extra") + if (parts.length !== 2) return extendsValue + + const scope = parts[0] + const name = parts[1] + + // If already has the prefix, accept it as-is + if (name.startsWith(UNSCOPED_SHAREABLE_PREFIX)) return `${scope}/${name}` + + // ESLint-style shorthand: "@scope/foo" -> "@scope/solhint-config-foo" + return `${scope}/${UNSCOPED_SHAREABLE_PREFIX}${name}` + } + + // unscoped: if already prefixed, accept it + if (extendsValue.startsWith(UNSCOPED_SHAREABLE_PREFIX)) return extendsValue + + // default: keep current behavior + return `${UNSCOPED_SHAREABLE_PREFIX}${extendsValue}` +} + +// ----------------------------------------------------------------------------- +// Shareable config resolver +// ----------------------------------------------------------------------------- + +// Cache the project require so we don't recreate it on each call. +// NOTE: This uses process.cwd() at module load time. In Solhint, config loading +// runs after the CLI has already set the working directory (your E2E does that), +// so this preserves current behavior and avoids repeated createRequire calls. +const projectRequire = (() => { + // Resolve shareable configs from the *project* (cwd) instead of from Solhint's installation path. + // This is important when Solhint is installed globally or when running from a different directory: + // - Users expect `extends` packages to be resolved from their project's node_modules. + // - E2E fixtures typically don't have a package.json, so we anchor to a dummy file in cwd. + // + // createRequire requires an absolute filename (not a directory). We anchor to a dummy file path. + // The file doesn't need to exist for resolution to work. + const anchor = path.join(process.cwd(), REQUIRE_ANCHOR_BASENAME) + return createRequire(anchor) +})() + +function requireFromProject(moduleId) { + return projectRequire(moduleId) +} + +const configGetter = (p) => { + const resolved = resolveShareableConfigName(p) + + if (isAbsolute(resolved)) { + return require(resolved) } - return path.startsWith('solhint:') - ? getSolhintCoreConfig(path) - : require(`solhint-config-${path}`) + + if (resolved.startsWith(CORE_PREFIX)) { + return getSolhintCoreConfig(resolved) + } + + // Shareable config (npm package): resolve from project's node_modules (cwd) + return requireFromProject(resolved) } const applyExtends = (config, getter = configGetter) => { @@ -121,4 +197,5 @@ module.exports = { configGetter, loadConfig, loadConfigForFile, + resolveShareableConfigName, } diff --git a/test/common/apply-extends.js b/test/common/apply-extends.js index 474f3956..ef44d4ab 100644 --- a/test/common/apply-extends.js +++ b/test/common/apply-extends.js @@ -1,5 +1,7 @@ +const path = require('path') const assert = require('assert') const { applyExtends } = require('../../lib/config/config-file') +const { resolveShareableConfigName } = require('../../lib/config/config-file') describe('applyExtends', () => { it('should return the same config if the extends property does not exist', () => { @@ -125,4 +127,57 @@ describe('applyExtends', () => { }, }) }) + + describe('resolveShareableConfigName', () => { + it('keeps solhint: core presets as-is', () => { + assert.equal(resolveShareableConfigName('solhint:recommended'), 'solhint:recommended') + assert.equal(resolveShareableConfigName('solhint:all'), 'solhint:all') + }) + + it('keeps absolute paths as-is (posix)', () => { + const abs = path.resolve('/tmp/some-config.js') + assert.equal(resolveShareableConfigName(abs), abs) + }) + + it('keeps absolute paths as-is (windows style) - only if running on win', () => { + // This avoids failing on non-win where path.isAbsolute('C:\\x') may behave differently + if (process.platform !== 'win32') return + const abs = 'C:\\temp\\some-config.js' + assert.equal(resolveShareableConfigName(abs), abs) + }) + + it('prefixes legacy unscoped configs (foo -> solhint-config-foo)', () => { + assert.equal(resolveShareableConfigName('foo'), 'solhint-config-foo') + }) + + it('does NOT double-prefix explicit unscoped packages (solhint-config-foo stays)', () => { + assert.equal(resolveShareableConfigName('solhint-config-foo'), 'solhint-config-foo') + }) + + it('keeps scoped configs that already include solhint-config- prefix', () => { + assert.equal( + resolveShareableConfigName('@scope/solhint-config-myconfig'), + '@scope/solhint-config-myconfig' + ) + }) + + it('maps @scope/foo -> @scope/solhint-config-foo (eslint-style)', () => { + assert.equal(resolveShareableConfigName('@scope/foo'), '@scope/solhint-config-foo') + }) + + it('does not try to “fix” malformed scoped values (let require fail upstream)', () => { + assert.equal(resolveShareableConfigName('@scope'), '@scope') + assert.equal(resolveShareableConfigName('@'), '@') + }) + + it('does not try to support deep scoped paths (leave as-is)', () => { + // @scope/pkg/extra is not a standard npm package spec for require() + assert.equal(resolveShareableConfigName('@scope/pkg/extra'), '@scope/pkg/extra') + }) + + it('keeps weird-but-valid unscoped values stable', () => { + // If someone uses uppercase or dots, we shouldn’t mangle beyond prefixing. + assert.equal(resolveShareableConfigName('Foo.Bar'), 'solhint-config-Foo.Bar') + }) + }) }) From 824fcd7e899b177cc227ea169fab58eec6c5e66c Mon Sep 17 00:00:00 2001 From: dbale-altoros Date: Fri, 23 Jan 2026 14:28:21 -0300 Subject: [PATCH 04/21] fix: ci --- .github/workflows/E2E.yml | 5 +++++ .gitignore | 8 ++++++++ .../node_modules/@test/solhint-config-demo/index.js | 5 +++++ .../node_modules/@test/solhint-config-base/index.js | 7 +++++++ .../node_modules/@test/solhint-config-base/index.js | 7 +++++++ .../project/node_modules/solhint-config-a/index.js | 2 ++ .../project/node_modules/solhint-config-b/index.js | 2 ++ .../project/node_modules/solhint-config-a/index.js | 2 ++ .../project/node_modules/solhint-config-b/index.js | 2 ++ .../node_modules/@test/solhint-config-demo/index.js | 2 ++ .../project/node_modules/solhint-config-demo/index.js | 2 ++ 11 files changed, 44 insertions(+) create mode 100644 e2e/14-shareable-config/filesystem01/project/node_modules/@test/solhint-config-demo/index.js create mode 100644 e2e/14-shareable-config/filesystem02/project/node_modules/@test/solhint-config-base/index.js create mode 100644 e2e/14-shareable-config/filesystem03/project/node_modules/@test/solhint-config-base/index.js create mode 100644 e2e/14-shareable-config/filesystem04/project/node_modules/solhint-config-a/index.js create mode 100644 e2e/14-shareable-config/filesystem04/project/node_modules/solhint-config-b/index.js create mode 100644 e2e/14-shareable-config/filesystem05/project/node_modules/solhint-config-a/index.js create mode 100644 e2e/14-shareable-config/filesystem05/project/node_modules/solhint-config-b/index.js create mode 100644 e2e/14-shareable-config/filesystem06/project/node_modules/@test/solhint-config-demo/index.js create mode 100644 e2e/14-shareable-config/filesystem07/project/node_modules/solhint-config-demo/index.js diff --git a/.github/workflows/E2E.yml b/.github/workflows/E2E.yml index 1d25ce0d..a40937dd 100644 --- a/.github/workflows/E2E.yml +++ b/.github/workflows/E2E.yml @@ -40,6 +40,11 @@ jobs: - name: Check solhint version run: solhint --version + + - name: Debug fixtures presence + run: | + ls -R e2e/14-shareable-config/filesystem01/project || true + ls -R e2e/14-shareable-config/filesystem04/project || true - name: Run E2E Tests run: npm run ci:e2e diff --git a/.gitignore b/.gitignore index 59dc59be..7e7e7088 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ +# ignore all node_modules by default node_modules/ + /.idea/ test2.sol convertLib.sol @@ -12,3 +14,9 @@ _temp/ .env **/.DS_Store solhint*.tgz + +# Exception on E2E fixtures' node_modules (they are part of the test data) +# only allow .js and .json files in those node_modules +!e2e/**/node_modules/**/*.js +!e2e/**/node_modules/**/*.json + diff --git a/e2e/14-shareable-config/filesystem01/project/node_modules/@test/solhint-config-demo/index.js b/e2e/14-shareable-config/filesystem01/project/node_modules/@test/solhint-config-demo/index.js new file mode 100644 index 00000000..d0243752 --- /dev/null +++ b/e2e/14-shareable-config/filesystem01/project/node_modules/@test/solhint-config-demo/index.js @@ -0,0 +1,5 @@ +module.exports = { + rules: { + 'no-empty-blocks': 'error' + } +} diff --git a/e2e/14-shareable-config/filesystem02/project/node_modules/@test/solhint-config-base/index.js b/e2e/14-shareable-config/filesystem02/project/node_modules/@test/solhint-config-base/index.js new file mode 100644 index 00000000..a91895ca --- /dev/null +++ b/e2e/14-shareable-config/filesystem02/project/node_modules/@test/solhint-config-base/index.js @@ -0,0 +1,7 @@ +module.exports = { + rules: { + // ruleto override in local configs + 'max-line-length': ['error', 100], + 'no-empty-blocks': 'error', + }, +} diff --git a/e2e/14-shareable-config/filesystem03/project/node_modules/@test/solhint-config-base/index.js b/e2e/14-shareable-config/filesystem03/project/node_modules/@test/solhint-config-base/index.js new file mode 100644 index 00000000..a91895ca --- /dev/null +++ b/e2e/14-shareable-config/filesystem03/project/node_modules/@test/solhint-config-base/index.js @@ -0,0 +1,7 @@ +module.exports = { + rules: { + // ruleto override in local configs + 'max-line-length': ['error', 100], + 'no-empty-blocks': 'error', + }, +} diff --git a/e2e/14-shareable-config/filesystem04/project/node_modules/solhint-config-a/index.js b/e2e/14-shareable-config/filesystem04/project/node_modules/solhint-config-a/index.js new file mode 100644 index 00000000..bef87eed --- /dev/null +++ b/e2e/14-shareable-config/filesystem04/project/node_modules/solhint-config-a/index.js @@ -0,0 +1,2 @@ +module.exports = { rules: { 'no-empty-blocks': 'warn' } } + diff --git a/e2e/14-shareable-config/filesystem04/project/node_modules/solhint-config-b/index.js b/e2e/14-shareable-config/filesystem04/project/node_modules/solhint-config-b/index.js new file mode 100644 index 00000000..2139ad6c --- /dev/null +++ b/e2e/14-shareable-config/filesystem04/project/node_modules/solhint-config-b/index.js @@ -0,0 +1,2 @@ +module.exports = { rules: { 'no-empty-blocks': 'error' } } + diff --git a/e2e/14-shareable-config/filesystem05/project/node_modules/solhint-config-a/index.js b/e2e/14-shareable-config/filesystem05/project/node_modules/solhint-config-a/index.js new file mode 100644 index 00000000..bef87eed --- /dev/null +++ b/e2e/14-shareable-config/filesystem05/project/node_modules/solhint-config-a/index.js @@ -0,0 +1,2 @@ +module.exports = { rules: { 'no-empty-blocks': 'warn' } } + diff --git a/e2e/14-shareable-config/filesystem05/project/node_modules/solhint-config-b/index.js b/e2e/14-shareable-config/filesystem05/project/node_modules/solhint-config-b/index.js new file mode 100644 index 00000000..2139ad6c --- /dev/null +++ b/e2e/14-shareable-config/filesystem05/project/node_modules/solhint-config-b/index.js @@ -0,0 +1,2 @@ +module.exports = { rules: { 'no-empty-blocks': 'error' } } + diff --git a/e2e/14-shareable-config/filesystem06/project/node_modules/@test/solhint-config-demo/index.js b/e2e/14-shareable-config/filesystem06/project/node_modules/@test/solhint-config-demo/index.js new file mode 100644 index 00000000..bef87eed --- /dev/null +++ b/e2e/14-shareable-config/filesystem06/project/node_modules/@test/solhint-config-demo/index.js @@ -0,0 +1,2 @@ +module.exports = { rules: { 'no-empty-blocks': 'warn' } } + diff --git a/e2e/14-shareable-config/filesystem07/project/node_modules/solhint-config-demo/index.js b/e2e/14-shareable-config/filesystem07/project/node_modules/solhint-config-demo/index.js new file mode 100644 index 00000000..2139ad6c --- /dev/null +++ b/e2e/14-shareable-config/filesystem07/project/node_modules/solhint-config-demo/index.js @@ -0,0 +1,2 @@ +module.exports = { rules: { 'no-empty-blocks': 'error' } } + From b9b0721f38cedfa08c0b76dcace1d5726d88d10c Mon Sep 17 00:00:00 2001 From: XiaoBei <1505929057@qq.com> Date: Tue, 10 Feb 2026 15:15:46 +0800 Subject: [PATCH 05/21] Update LICENSE --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index ab545b47..0ffdb39b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017-2025 Protofire +Copyright (c) 2017-2026 Protofire Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 3afc01196ba11326fb455bb26d27df0dd2d89546 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:15:25 +0000 Subject: [PATCH 06/21] chore(deps): bump ajv from 6.12.6 to 8.18.0 Bumps [ajv](https://github.com/ajv-validator/ajv) from 6.12.6 to 8.18.0. - [Release notes](https://github.com/ajv-validator/ajv/releases) - [Commits](https://github.com/ajv-validator/ajv/compare/v6.12.6...v8.18.0) --- updated-dependencies: - dependency-name: ajv dependency-version: 8.18.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 95 +++++++++++++++++++++++++++++++---------------- package.json | 2 +- 2 files changed, 63 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index 79cb3a39..d7468305 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@solidity-parser/parser": "^0.20.2", - "ajv": "^6.12.6", + "ajv": "^8.18.0", "ajv-errors": "^1.0.1", "ast-parents": "^0.0.1", "better-ajv-errors": "^2.0.2", @@ -386,6 +386,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", @@ -933,15 +957,15 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -2379,6 +2403,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -2473,6 +2521,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -4015,9 +4064,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -5208,6 +5257,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6220,28 +6270,6 @@ "node": ">=10.0.0" } }, - "node_modules/table/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/table/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -6512,6 +6540,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" diff --git a/package.json b/package.json index 767dc0ee..13170fdb 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "license": "MIT", "dependencies": { "@solidity-parser/parser": "^0.20.2", - "ajv": "^6.12.6", + "ajv": "^8.18.0", "ajv-errors": "^1.0.1", "ast-parents": "^0.0.1", "better-ajv-errors": "^2.0.2", From 24c61c4f061ed46dbca63d35a6d39e1d2d6653b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Feb 2026 03:59:29 +0000 Subject: [PATCH 07/21] chore(deps): bump minimatch Bumps and [minimatch](https://github.com/isaacs/minimatch). These dependencies needed to be updated together. Updates `minimatch` from 5.1.6 to 5.1.9 - [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md) - [Commits](https://github.com/isaacs/minimatch/compare/v5.1.6...v5.1.9) Updates `minimatch` from 3.1.2 to 3.1.5 - [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md) - [Commits](https://github.com/isaacs/minimatch/compare/v5.1.6...v5.1.9) Updates `minimatch` from 10.1.1 to 10.2.4 - [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md) - [Commits](https://github.com/isaacs/minimatch/compare/v5.1.6...v5.1.9) --- updated-dependencies: - dependency-name: minimatch dependency-version: 5.1.9 dependency-type: indirect - dependency-name: minimatch dependency-version: 3.1.5 dependency-type: indirect - dependency-name: minimatch dependency-version: 10.2.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 74 +++++++++++++++++++++++------------------------ 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 79cb3a39..0fce9fc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -443,29 +443,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2914,9 +2891,9 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -4256,9 +4233,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4344,9 +4321,9 @@ } }, "node_modules/mocha/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", "dependencies": { @@ -5507,6 +5484,29 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/rimraf/node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -5549,16 +5549,16 @@ } }, "node_modules/rimraf/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" From 970629914c922faa3c913b45a407c6d0537b7a3c Mon Sep 17 00:00:00 2001 From: dbale-altoros Date: Thu, 5 Mar 2026 12:25:40 -0300 Subject: [PATCH 08/21] fix: natspect on unnamed params --- docs/rules/best-practices/use-natspec.md | 2 +- lib/rules/best-practices/use-natspec.js | 22 +++--- test/rules/best-practices/use-natspec.js | 89 ++++++++++++++++-------- 3 files changed, 68 insertions(+), 45 deletions(-) diff --git a/docs/rules/best-practices/use-natspec.md b/docs/rules/best-practices/use-natspec.md index 149c8b1c..9df2d327 100644 --- a/docs/rules/best-practices/use-natspec.md +++ b/docs/rules/best-practices/use-natspec.md @@ -59,7 +59,7 @@ This rule accepts an array of options: ### Notes - For `internal` or `private` function this rule checks natspec as configured, only if there are tags present. If not, function is skipped. - For `external` or `public` function this rule checks natspec as specified in the config. -- If a function or return value has unnamed parameters (e.g. `function foo(uint256)`), the rule only checks the number of `@param` or `@return` tags, not their names. +- If a function or return value has unnamed parameters (e.g. `function foo(uint256, uint256 _amount)`), the rule DOES not check ANY of the params/return tags - If a function or variable has `@inheritdoc`, the rule skips the validation. - The rule supports both `///` and `/** */` style NatSpec comments. - If a custom config is provided, it is merged with the default config. Only the overridden fields need to be specified. diff --git a/lib/rules/best-practices/use-natspec.js b/lib/rules/best-practices/use-natspec.js index 177cac6a..2533649c 100644 --- a/lib/rules/best-practices/use-natspec.js +++ b/lib/rules/best-practices/use-natspec.js @@ -209,7 +209,7 @@ const meta = { note: 'For `external` or `public` function this rule checks natspec as specified in the config.', }, { - note: 'If a function or return value has unnamed parameters (e.g. `function foo(uint256)`), the rule only checks the number of `@param` or `@return` tags, not their names.', + note: 'If a function or return value has unnamed parameters (e.g. `function foo(uint256, uint256 _amount)`), the rule DOES not check ANY of the params/return tags', }, { note: 'If a function or variable has `@inheritdoc`, the rule skips the validation.', @@ -295,7 +295,7 @@ class UseNatspecChecker extends BaseChecker { if (!rule?.enabled) continue if (!shouldValidateTag(tag, type, name, this.tagConfig)) continue - if (!tags.includes(tag)) { + if (!tags.includes(tag) && tag !== 'param' && tag !== 'return') { this.reporter.warn(node, ruleId, `Missing @${tag} tag in ${type} '${name}'`) } @@ -310,6 +310,9 @@ class UseNatspecChecker extends BaseChecker { const allParamsHaveNames = namedParams.length === solidityParams.length if (allParamsHaveNames) { + if (!tags.includes(tag)) { + this.reporter.warn(node, ruleId, `Missing @${tag} tag in ${type} '${name}'`) + } if (namedParams.length !== docParams.length || !arraysEqual(namedParams, docParams)) { this.reporter.warn( node, @@ -319,12 +322,6 @@ class UseNatspecChecker extends BaseChecker { )}], Found: [${docParams.join(', ')}]` ) } - } else if (solidityParams.length !== docParams.length) { - this.reporter.warn( - node, - ruleId, - `Mismatch in @param count for ${type} '${name}'. Expected: ${solidityParams.length}, Found: ${docParams.length}` - ) } } @@ -339,6 +336,9 @@ class UseNatspecChecker extends BaseChecker { const allReturnsHaveNames = namedReturns.length === solidityReturns.length if (allReturnsHaveNames) { + if (!tags.includes(tag)) { + this.reporter.warn(node, ruleId, `Missing @${tag} tag in ${type} '${name}'`) + } if (namedReturns.length !== docReturns.length || !arraysEqual(namedReturns, docReturns)) { this.reporter.warn( node, @@ -348,12 +348,6 @@ class UseNatspecChecker extends BaseChecker { )}], Found: [${docReturns.join(', ')}]` ) } - } else if (solidityReturns.length !== docReturns.length) { - this.reporter.warn( - node, - ruleId, - `Mismatch in @return count for ${type} '${name}'. Expected: ${solidityReturns.length}, Found: ${docReturns.length}` - ) } } } diff --git a/test/rules/best-practices/use-natspec.js b/test/rules/best-practices/use-natspec.js index bf1a3f2f..7942dd38 100644 --- a/test/rules/best-practices/use-natspec.js +++ b/test/rules/best-practices/use-natspec.js @@ -207,27 +207,6 @@ describe('Linter - use-natspec', () => { ) }) - it('should fail when natspec @param tags quantity does not match Solidity parameters', () => { - const code = ` - /// @title A - /// @author B - /// @notice test - contract C { - /// @notice does something - /// @param description - function test(uint, uint) external {} - } - ` - const report = linter.processStr(code, { - rules: { 'use-natspec': 'error' }, - }) - - assertErrorMessage( - report, - "Mismatch in @param count for function 'test'. Expected: 2, Found: 1" - ) - }) - it('should pass when unnamed Solidity params match @param count', () => { const code = ` /// @title A @@ -269,15 +248,15 @@ describe('Linter - use-natspec', () => { ) }) - it('should fail when one named return is undocumented', () => { + it('should check when return parameters ara not all named', () => { const code = ` /// @title A /// @author B /// @notice test contract C { /// @notice does something - /// @return amount description - function test() external pure returns (uint256, uint256 amount) { + /// @return amount1 description + function test() external pure returns (uint256 amount1, uint256) { return (1, amount); } } @@ -286,10 +265,7 @@ describe('Linter - use-natspec', () => { rules: { 'use-natspec': 'error' }, }) - assertErrorMessage( - report, - "Mismatch in @return count for function 'test'. Expected: 2, Found: 1" - ) + assertNoErrors(report) }) it('should ignore function if @inheritdoc is present', () => { @@ -509,7 +485,7 @@ describe('Linter - use-natspec', () => { const code = ` contract HalfIgnored { - function doSomething(uint256 amount) external returns(uint256) {} + function doSomething(uint256 amount) external returns(uint256 retParam) {} } ` @@ -590,7 +566,7 @@ describe('Linter - use-natspec', () => { /// @notice does something /// @param amount1 the amount - function doSomething1(uint256 amount) internal returns(uint256){} + function doSomething1(uint256 amount) internal returns(uint256 retParam){} } ` @@ -633,4 +609,57 @@ describe('Linter - use-natspec', () => { ) }) }) + + // ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe('REPO Issues reported', () => { + it('Test Github Issue 742 - 1 -> unnamed parameters and mixed should not report', () => { + const code = ` + /// @title A + /// @author B + /// @notice Some notice + contract C { + + /** + * @notice Example1 + */ + function oneNoNamedParam(bytes calldata /*message*/) external {} + + /// @notice Example2 + function mixedNamedParams(uint256 named, uint256) external {} + } + ` + + const report = linter.processStr(code, { + rules: { 'use-natspec': 'error' }, + }) + + assertNoErrors(report) + }) + + // it.only('Test Github Issue 743 - 1 -> ', () => { + // const code = ` + // pragma solidity ^0.8.30; + + // /// @title A + // /// @author B + // /// @notice Some notice + // contract Test { + // /** + // * @notice Example function + // */ + // // solhint-disable-next-line + // function f() external returns(uint256) {} + // } + // ` + + // const report = linter.processStr(code, { + // rules: { 'use-natspec': 'error', 'gas-named-return-values': 'error' }, + // }) + + // assertNoErrors(report) + // }) + }) }) From bbebe433c5697eea0bb338f3f6c6263dcff675fe Mon Sep 17 00:00:00 2001 From: dbale-altoros Date: Tue, 10 Mar 2026 14:40:15 -0300 Subject: [PATCH 09/21] fix: natspec rule and other issues --- .../filesystem01/project/.solhint.json | 8 +++ .../project/contracts/EmptyBlocks.sol | 6 +++ e2e/test.js | 21 ++++++++ lib/rules/index.js | 51 ++++++++++++------ lib/rules/miscellaneous/import-path-check.js | 52 +++++++++---------- test/rules/best-practices/use-natspec.js | 23 -------- 6 files changed, 93 insertions(+), 68 deletions(-) create mode 100644 e2e/15-plugins/filesystem01/project/.solhint.json create mode 100644 e2e/15-plugins/filesystem01/project/contracts/EmptyBlocks.sol diff --git a/e2e/15-plugins/filesystem01/project/.solhint.json b/e2e/15-plugins/filesystem01/project/.solhint.json new file mode 100644 index 00000000..7d520fb1 --- /dev/null +++ b/e2e/15-plugins/filesystem01/project/.solhint.json @@ -0,0 +1,8 @@ +{ + "plugins": ["demo"], + "rules": { + "demo/plugin-rule-test": "error", + "no-empty-blocks": "error", + "non-existent-rule": "error" + } +} diff --git a/e2e/15-plugins/filesystem01/project/contracts/EmptyBlocks.sol b/e2e/15-plugins/filesystem01/project/contracts/EmptyBlocks.sol new file mode 100644 index 00000000..4a07940b --- /dev/null +++ b/e2e/15-plugins/filesystem01/project/contracts/EmptyBlocks.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract C { function f() public { } } + + diff --git a/e2e/test.js b/e2e/test.js index 8590a4e8..28a7a189 100644 --- a/e2e/test.js +++ b/e2e/test.js @@ -537,6 +537,27 @@ describe('e2e general tests', function () { expect(stderr + stdout).to.include('Failed to load config "@test/demo/extra"') }) }) + + describe('plugins', function () { + const PATH = '15-plugins/filesystem' + let folderCounter = 1 + + beforeEach(() => { + const padded = String(folderCounter).padStart(2, '0') + + const ROOT = PATH + padded + '/' + useFixtureFolder(this, ROOT + 'project') + + folderCounter++ + }) + + it('should load plugin - fs1', () => { + const { code, stdout, stderr } = shell.exec(`solhint contracts/EmptyBlocks.sol`) + + expect(code).to.equal(EXIT_CODES.REPORTED_ERRORS) + expect(stderr + stdout).to.include('plugin rule executed') + }) + }) }) function useFixture(dir) { diff --git a/lib/rules/index.js b/lib/rules/index.js index 126f176d..40d1afe7 100644 --- a/lib/rules/index.js +++ b/lib/rules/index.js @@ -1,5 +1,6 @@ const chalk = require('chalk') const _ = require('lodash') +const path = require('path') const security = require('./security/index') const naming = require('./naming/index') const order = require('./order/index') @@ -18,7 +19,7 @@ const notifyRuleDeprecated = _.memoize((ruleId, deprecationMessage) => { }) const notifyRuleDoesntExist = _.memoize((ruleId) => { - console.warn(chalk.yellow(`[solhint] Warning: Rule '${ruleId}' doesn't exist`)) + console.warn(chalk.yellow(`\n ===>>>> [solhint] Warning: Rule '${ruleId}' doesn't exist`)) }) module.exports = function checkers(reporter, configVals, inputSrc, tokens, fileName) { @@ -71,32 +72,48 @@ function coreRules(meta) { function loadPlugin(pluginName, { reporter, config, inputSrc, fileName }) { let plugins + const id = `solhint-plugin-${pluginName}` + try { - plugins = require(`solhint-plugin-${pluginName}`) + let extraPluginPaths = [] + + if (Array.isArray(config.pluginPaths)) { + extraPluginPaths = config.pluginPaths + } else if (config.pluginPaths) { + extraPluginPaths = [config.pluginPaths] + } + + const resolutionPaths = [ + process.cwd(), + ...extraPluginPaths, + ...extraPluginPaths.map((p) => path.join(p, 'node_modules')), + ] + + const resolved = require.resolve(id, { paths: resolutionPaths }) + plugins = require(resolved) + + // support ESM default export + if (!Array.isArray(plugins) && Array.isArray(plugins?.default)) { + plugins = plugins.default + } } catch (e) { - console.error( - chalk.red( - `[solhint] Error: Could not load solhint-plugin-${pluginName}, make sure it's installed.` - ) - ) - process.exit(1) + const reason = e && e.message ? ` Reason: ${e.message}` : '' + console.warn(chalk.yellow(`[solhint] Warning: Could not load ${id}, ignoring it.${reason}`)) + return [] } if (!Array.isArray(plugins)) { console.warn( - chalk.yellow( - `[solhint] Warning: Plugin solhint-plugin-${pluginName} doesn't export an array of rules. Ignoring it.` - ) + chalk.yellow(`[solhint] Warning: Plugin ${id} doesn't export an array of rules. Ignoring it.`) ) return [] } - return plugins - .map((Plugin) => new Plugin(reporter, config, inputSrc, fileName)) - .map((plugin) => { - plugin.ruleId = `${pluginName}/${plugin.ruleId}` - return plugin - }) + const instances = plugins.map((Plugin) => new Plugin(reporter, config, inputSrc, fileName)) + return instances.map((plugin) => { + plugin.ruleId = `${pluginName}/${plugin.ruleId}` + return plugin + }) } function pluginsRules(configPlugins, meta) { diff --git a/lib/rules/miscellaneous/import-path-check.js b/lib/rules/miscellaneous/import-path-check.js index 4093374a..e221d4c1 100644 --- a/lib/rules/miscellaneous/import-path-check.js +++ b/lib/rules/miscellaneous/import-path-check.js @@ -5,30 +5,6 @@ const { severityDescription } = require('../../doc/utils') const DEFAULT_SEVERITY = 'warn' const LOCATION_REPLACEMENT_FLAG = '[~dependenciesPath]' -const DEFAULT_LOCATIONS = [ - // import related to base path - path.resolve(process.cwd()), - // typical contracts path - path.resolve(process.cwd(), 'contracts'), - path.resolve(process.cwd(), 'src'), - // hardhat typical paths - path.resolve(process.cwd(), 'node_modules'), - path.resolve(process.cwd(), 'artifacts'), - path.resolve(process.cwd(), 'cache'), - // foundry typical paths - path.resolve(process.cwd(), 'lib'), - path.resolve(process.cwd(), 'out'), - // npm global (Linux/macOS) - '/usr/local/lib/node_modules', - // nvm global (Linux/macOS) - path.join(process.env.HOME || '', '.nvm/versions/node', process.version, 'lib/node_modules'), - // yarn global (Linux/macOS) - path.join(process.env.HOME || '', '.yarn/global/node_modules'), - // npm global (Windows) - path.join(process.env.APPDATA || '', 'npm/node_modules'), - // yarn global (Windows) - path.join(process.env.LOCALAPPDATA || '', 'Yarn/Data/global/node_modules'), -] const ruleId = 'import-path-check' const meta = { @@ -65,7 +41,7 @@ const meta = { }, { note: [ - '*Default Locations:*', + '*DEFAULT_LOCATIONS:*', '/[`~current-project`]', '/[`~current-project`]/contracts', '/[`~current-project`]/src', @@ -97,15 +73,35 @@ const meta = { }, } +function getDefaultLocations() { + return [ + path.resolve(process.cwd()), + path.resolve(process.cwd(), 'contracts'), + path.resolve(process.cwd(), 'src'), + path.resolve(process.cwd(), 'node_modules'), + path.resolve(process.cwd(), 'artifacts'), + path.resolve(process.cwd(), 'cache'), + path.resolve(process.cwd(), 'lib'), + path.resolve(process.cwd(), 'out'), + '/usr/local/lib/node_modules', + path.join(process.env.HOME || '', '.nvm/versions/node', process.version, 'lib/node_modules'), + path.join(process.env.HOME || '', '.yarn/global/node_modules'), + path.join(process.env.APPDATA || '', 'npm/node_modules'), + path.join(process.env.LOCALAPPDATA || '', 'Yarn/Data/global/node_modules'), + ] +} + class ImportPathChecker extends BaseChecker { constructor(reporter, config, fileName) { super(reporter, ruleId, meta) - this.searchOn = [] + const arr = config ? config.getArray(ruleId) : [] + const defaultLocations = getDefaultLocations() + if (!Array.isArray(arr) || arr.length === 0 || arr[0] === LOCATION_REPLACEMENT_FLAG) { - this.searchOn = [...DEFAULT_LOCATIONS] + this.searchOn = [...defaultLocations] } else { - this.searchOn = arr.concat(DEFAULT_LOCATIONS) + this.searchOn = arr.concat(defaultLocations) } this.fileName = fileName diff --git a/test/rules/best-practices/use-natspec.js b/test/rules/best-practices/use-natspec.js index 7942dd38..81cddcc4 100644 --- a/test/rules/best-practices/use-natspec.js +++ b/test/rules/best-practices/use-natspec.js @@ -638,28 +638,5 @@ describe('Linter - use-natspec', () => { assertNoErrors(report) }) - - // it.only('Test Github Issue 743 - 1 -> ', () => { - // const code = ` - // pragma solidity ^0.8.30; - - // /// @title A - // /// @author B - // /// @notice Some notice - // contract Test { - // /** - // * @notice Example function - // */ - // // solhint-disable-next-line - // function f() external returns(uint256) {} - // } - // ` - - // const report = linter.processStr(code, { - // rules: { 'use-natspec': 'error', 'gas-named-return-values': 'error' }, - // }) - - // assertNoErrors(report) - // }) }) }) From 1b0962192394259501651213036016bc72112d4f Mon Sep 17 00:00:00 2001 From: dbale-altoros Date: Tue, 10 Mar 2026 14:40:44 -0300 Subject: [PATCH 10/21] fix: natspec rule and other issues --- docs/rules/miscellaneous/import-path-check.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rules/miscellaneous/import-path-check.md b/docs/rules/miscellaneous/import-path-check.md index c338c7f4..8c23324a 100644 --- a/docs/rules/miscellaneous/import-path-check.md +++ b/docs/rules/miscellaneous/import-path-check.md @@ -44,7 +44,7 @@ This rule accepts an array of options: - If `searchOn` has value, will be concatenated with DEFAULT_LOCATIONS. - If config has `extends:recommended` or `all` and rule is overwritten with `searchOn`, values are concatenated with DEFAULT_LOCATIONS. - *Default Locations:* + *DEFAULT_LOCATIONS:* - /[`~current-project`] - /[`~current-project`]/contracts - /[`~current-project`]/src From ef86710681694c7198a6f3913b5ff44d70566dd4 Mon Sep 17 00:00:00 2001 From: dbale-altoros Date: Tue, 10 Mar 2026 14:48:00 -0300 Subject: [PATCH 11/21] fix: natspec rule and other issues --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 7e7e7088..8a293e77 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ _temp/ solhint*.tgz # Exception on E2E fixtures' node_modules (they are part of the test data) -# only allow .js and .json files in those node_modules +# only allow useful test files in those node_modules !e2e/**/node_modules/**/*.js !e2e/**/node_modules/**/*.json - +!e2e/15-plugins/**/**/node_modules/**/*.* From 79986f15e1ffef2ad825be24a7937621ce158d78 Mon Sep 17 00:00:00 2001 From: dbale-altoros Date: Tue, 10 Mar 2026 15:14:52 -0300 Subject: [PATCH 12/21] fix: natspec rule and other issues --- .gitignore | 4 +++- .../node_modules/solhint-plugin-demo/index.js | 3 +++ .../solhint-plugin-demo/rules/pluginRule.js | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 e2e/15-plugins/filesystem01/project/node_modules/solhint-plugin-demo/index.js create mode 100644 e2e/15-plugins/filesystem01/project/node_modules/solhint-plugin-demo/rules/pluginRule.js diff --git a/.gitignore b/.gitignore index 8a293e77..0d8a710c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,6 @@ solhint*.tgz # only allow useful test files in those node_modules !e2e/**/node_modules/**/*.js !e2e/**/node_modules/**/*.json -!e2e/15-plugins/**/**/node_modules/**/*.* + +# allow test plugins inside e2e fixtures +!e2e/15-plugins/** diff --git a/e2e/15-plugins/filesystem01/project/node_modules/solhint-plugin-demo/index.js b/e2e/15-plugins/filesystem01/project/node_modules/solhint-plugin-demo/index.js new file mode 100644 index 00000000..5877b76b --- /dev/null +++ b/e2e/15-plugins/filesystem01/project/node_modules/solhint-plugin-demo/index.js @@ -0,0 +1,3 @@ +module.exports = [ + require("./rules/pluginRule"), +] \ No newline at end of file diff --git a/e2e/15-plugins/filesystem01/project/node_modules/solhint-plugin-demo/rules/pluginRule.js b/e2e/15-plugins/filesystem01/project/node_modules/solhint-plugin-demo/rules/pluginRule.js new file mode 100644 index 00000000..6d149f8e --- /dev/null +++ b/e2e/15-plugins/filesystem01/project/node_modules/solhint-plugin-demo/rules/pluginRule.js @@ -0,0 +1,15 @@ +const meta = { type: "misc", docs: { description: "demo plugin rule" }, schema: null } + +class PluginRule { + constructor(reporter) { + this.reporter = reporter + this.ruleId = "plugin-rule-test" // loader lo convertirá a demo/plugin-rule-test + this.meta = meta + } + + SourceUnit(node) { + this.reporter.errorAt(1, 0, this.ruleId, "plugin rule executed") + } +} + +module.exports = PluginRule \ No newline at end of file From fb0a24909eeee6eabb9d040c1123c37e363b2ad4 Mon Sep 17 00:00:00 2001 From: Diego Bale <89921440+dbale-altoros@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:33:28 -0300 Subject: [PATCH 13/21] Add pluginPaths support for plugin resolution --- lib/config/config-schema.js | 1 + lib/rules/index.js | 35 ++++- test/common/config-validator.js | 22 ++++ test/common/plugin-loading.js | 227 ++++++++++++++++++++++++++++++++ 4 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 test/common/plugin-loading.js diff --git a/lib/config/config-schema.js b/lib/config/config-schema.js index b5ab764a..33391027 100644 --- a/lib/config/config-schema.js +++ b/lib/config/config-schema.js @@ -2,6 +2,7 @@ const baseConfigProperties = { rules: { type: 'object', additionalProperties: true }, excludedFiles: { type: 'array', items: { type: 'string' } }, plugins: { type: 'array', items: { type: 'string' } }, + pluginPaths: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] }, extends: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] }, globals: { type: 'object' }, env: { type: 'object' }, diff --git a/lib/rules/index.js b/lib/rules/index.js index 126f176d..a5088945 100644 --- a/lib/rules/index.js +++ b/lib/rules/index.js @@ -1,5 +1,6 @@ const chalk = require('chalk') const _ = require('lodash') +const path = require('path') const security = require('./security/index') const naming = require('./naming/index') const order = require('./order/index') @@ -69,23 +70,45 @@ function coreRules(meta) { ] } +// Accept both `pluginPaths: "..."` and `pluginPaths: ["..."]` in config. +function normalizePluginPaths(pluginPaths) { + const configuredPaths = Array.isArray(pluginPaths) ? pluginPaths : [pluginPaths] + + return configuredPaths.filter((pluginPath) => _.isString(pluginPath) && pluginPath.trim()) +} + +// Keep cwd resolution for backwards compatibility, then extend with configured locations. +function pluginResolutionPaths(config) { + const configuredPaths = normalizePluginPaths(config.pluginPaths) + + return [ + process.cwd(), + ...configuredPaths, + ...configuredPaths.map((pluginPath) => path.join(pluginPath, 'node_modules')), + ] +} + function loadPlugin(pluginName, { reporter, config, inputSrc, fileName }) { let plugins + const packageName = `solhint-plugin-${pluginName}` try { - plugins = require(`solhint-plugin-${pluginName}`) + const pluginModulePath = require.resolve(packageName, { paths: pluginResolutionPaths(config) }) + plugins = require(pluginModulePath) } catch (e) { - console.error( - chalk.red( - `[solhint] Error: Could not load solhint-plugin-${pluginName}, make sure it's installed.` + console.warn( + chalk.yellow( + `[solhint] Warning: Could not load ${packageName}, make sure it's installed and exports valid rules. Ignoring it.` ) ) - process.exit(1) + return [] } + plugins = plugins && plugins.default ? plugins.default : plugins + if (!Array.isArray(plugins)) { console.warn( chalk.yellow( - `[solhint] Warning: Plugin solhint-plugin-${pluginName} doesn't export an array of rules. Ignoring it.` + `[solhint] Warning: Plugin ${packageName} doesn't export an array of rules. Ignoring it.` ) ) return [] diff --git a/test/common/config-validator.js b/test/common/config-validator.js index 2a212c20..65ffc937 100644 --- a/test/common/config-validator.js +++ b/test/common/config-validator.js @@ -46,6 +46,28 @@ describe('Config validator', () => { assert.throws(() => validate(config), Error) }) + it('should validate config with pluginPaths array', () => { + const config = { + pluginPaths: ['/tmp/solhint-plugins'], + rules: { + 'avoid-throw': 'off', + }, + } + + assert.deepStrictEqual(_.isUndefined(validate(config)), true) + }) + + it('should validate config with pluginPaths string', () => { + const config = { + pluginPaths: '/tmp/solhint-plugins', + rules: { + 'avoid-throw': 'off', + }, + } + + assert.deepStrictEqual(_.isUndefined(validate(config)), true) + }) + it('should work with an empty config', () => { const config = {} diff --git a/test/common/plugin-loading.js b/test/common/plugin-loading.js new file mode 100644 index 00000000..8aea3732 --- /dev/null +++ b/test/common/plugin-loading.js @@ -0,0 +1,227 @@ +const assert = require('assert') +const fs = require('fs') +const os = require('os') +const path = require('path') +const sinon = require('sinon') +const rimraf = require('rimraf') +const linter = require('../../lib/index') + +function mkTmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)) +} + +function createPluginPackage(baseDir, pluginName, exportBody) { + const pluginDir = path.join(baseDir, `solhint-plugin-${pluginName}`) + fs.mkdirSync(pluginDir, { recursive: true }) + + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify({ + name: `solhint-plugin-${pluginName}`, + version: '1.0.0', + main: 'index.js', + }) + ) + + fs.writeFileSync(path.join(pluginDir, 'index.js'), exportBody) + + return pluginDir +} + +function pluginExportBody(ruleId, message = 'Plugin triggered') { + return ` +class TestPluginRule { + constructor(reporter) { + this.reporter = reporter; + this.ruleId = '${ruleId}'; + } + + ContractDefinition(node) { + this.reporter.error(node, this.ruleId, '${message}'); + } +} + +module.exports = [TestPluginRule]; +` +} + +function pluginDefaultExportBody(ruleId, message = 'Plugin triggered') { + return ` +class TestPluginRule { + constructor(reporter) { + this.reporter = reporter; + this.ruleId = '${ruleId}'; + } + + ContractDefinition(node) { + this.reporter.error(node, this.ruleId, '${message}'); + } +} + +module.exports = { default: [TestPluginRule] }; +` +} + +describe('Plugin loading', () => { + const source = 'pragma solidity ^0.8.0; contract C {}' + let initialCwd + let warnSpy + let tempDirs + + beforeEach(() => { + initialCwd = process.cwd() + warnSpy = sinon.spy(console, 'warn') + tempDirs = [] + }) + + afterEach(() => { + process.chdir(initialCwd) + warnSpy.restore() + for (const dir of tempDirs) { + rimraf.sync(dir) + } + }) + + it('loads plugin from project local node_modules', () => { + const projectDir = mkTmpDir('solhint-plugin-project-') + tempDirs.push(projectDir) + + const nodeModulesDir = path.join(projectDir, 'node_modules') + fs.mkdirSync(nodeModulesDir) + createPluginPackage(nodeModulesDir, 'project-local', pluginExportBody('project-local-rule')) + + process.chdir(projectDir) + + const report = linter.processStr(source, { + plugins: ['project-local'], + rules: { 'project-local/project-local-rule': 'error' }, + }) + + assert.equal(report.errorCount, 1) + }) + + it('loads plugin from pluginPaths provided as a string', () => { + const projectDir = mkTmpDir('solhint-plugin-path-string-') + const externalProjectDir = mkTmpDir('solhint-plugin-external-string-') + tempDirs.push(projectDir, externalProjectDir) + + const externalNodeModulesDir = path.join(externalProjectDir, 'node_modules') + fs.mkdirSync(externalNodeModulesDir) + createPluginPackage(externalNodeModulesDir, 'string-path', pluginExportBody('string-path-rule')) + + process.chdir(projectDir) + + const report = linter.processStr(source, { + pluginPaths: externalProjectDir, + plugins: ['string-path'], + rules: { 'string-path/string-path-rule': 'error' }, + }) + + assert.equal(report.errorCount, 1) + }) + + it('loads plugin when plugin is under /node_modules', () => { + const projectDir = mkTmpDir('solhint-plugin-path-parent-') + const externalProjectDir = mkTmpDir('solhint-plugin-external-project-') + tempDirs.push(projectDir, externalProjectDir) + + const externalNodeModulesDir = path.join(externalProjectDir, 'node_modules') + fs.mkdirSync(externalNodeModulesDir) + createPluginPackage(externalNodeModulesDir, 'parent-path', pluginExportBody('parent-path-rule')) + + process.chdir(projectDir) + + const report = linter.processStr(source, { + pluginPaths: [externalProjectDir], + plugins: ['parent-path'], + rules: { 'parent-path/parent-path-rule': 'error' }, + }) + + assert.equal(report.errorCount, 1) + }) + + it('missing plugin in plugins does not abort linting', () => { + const report = linter.processStr('pragma solidity ^0.4.4; contract C {}', { + plugins: ['missing-plugin-206'], + rules: { 'compiler-version': 'error' }, + }) + + assert.equal(report.errorCount, 1) + assert.ok(warnSpy.called) + assert.ok( + warnSpy.getCalls().some((call) => String(call.args[0]).includes('missing-plugin-206')), + 'should warn when plugin cannot be loaded' + ) + }) + + it('one missing plugin does not prevent another valid plugin from loading', () => { + const projectDir = mkTmpDir('solhint-plugin-mixed-') + tempDirs.push(projectDir) + + const nodeModulesDir = path.join(projectDir, 'node_modules') + fs.mkdirSync(nodeModulesDir) + createPluginPackage(nodeModulesDir, 'valid-plugin', pluginExportBody('valid-rule')) + + process.chdir(projectDir) + + const report = linter.processStr(source, { + plugins: ['missing-plugin-207', 'valid-plugin'], + rules: { + 'compiler-version': 'error', + 'valid-plugin/valid-rule': 'error', + }, + }) + + assert.equal(report.errorCount, 2) + }) + + it('supports plugin default export array', () => { + const projectDir = mkTmpDir('solhint-plugin-default-export-') + tempDirs.push(projectDir) + + const nodeModulesDir = path.join(projectDir, 'node_modules') + fs.mkdirSync(nodeModulesDir) + createPluginPackage( + nodeModulesDir, + 'default-export', + pluginDefaultExportBody('default-export-rule') + ) + + process.chdir(projectDir) + + const report = linter.processStr(source, { + plugins: ['default-export'], + rules: { 'default-export/default-export-rule': 'error' }, + }) + + assert.equal(report.errorCount, 1) + }) + + it('supports multiple pluginPaths', () => { + const projectDir = mkTmpDir('solhint-plugin-multiple-paths-') + const externalOne = mkTmpDir('solhint-plugin-path-one-') + const externalTwo = mkTmpDir('solhint-plugin-path-two-') + tempDirs.push(projectDir, externalOne, externalTwo) + + const externalOneNodeModules = path.join(externalOne, 'node_modules') + fs.mkdirSync(externalOneNodeModules) + createPluginPackage(externalOneNodeModules, 'multi-one', pluginExportBody('multi-one-rule')) + + const externalTwoNodeModules = path.join(externalTwo, 'node_modules') + fs.mkdirSync(externalTwoNodeModules) + createPluginPackage(externalTwoNodeModules, 'multi-two', pluginExportBody('multi-two-rule')) + + process.chdir(projectDir) + + const report = linter.processStr(source, { + pluginPaths: [externalOne, externalTwo], + plugins: ['multi-one', 'multi-two'], + rules: { + 'multi-one/multi-one-rule': 'error', + 'multi-two/multi-two-rule': 'error', + }, + }) + + assert.equal(report.errorCount, 2) + }) +}) From a291d33b56bc6be34c03c17214fee8278fef8e74 Mon Sep 17 00:00:00 2001 From: Diego Bale <89921440+dbale-altoros@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:43:14 -0300 Subject: [PATCH 14/21] Document pluginPaths plugin resolution behavior --- README.md | 25 +++++++++++++++++++++++++ docs/configuration.md | 22 ++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/README.md b/README.md index c2062d55..f995b117 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,31 @@ Project ROOT => A full list of all supported rules can be found [here](docs/rules.md).

+ +### Plugins and `pluginPaths` + +Solhint resolves plugins using Node resolution from: + +1. `process.cwd()` (default behavior, unchanged) +2. each configured entry in `pluginPaths` +3. each configured `/node_modules` + +This makes plugin loading work in environments where Solhint runs outside the project folder (for example IDE/editor integrations), while preserving the standard local `node_modules` behavior. + +Example: + +```json +{ + "pluginPaths": ["/some/path"], + "plugins": ["myplugin"], + "rules": { + "myplugin/some-rule": "error" + } +} +``` + +If a plugin fails to load, Solhint will warn and continue linting with core rules and any other valid plugins. + ### Ignore Configuration You can exclude files from linting using a `.solhintignore` file (name by default) or `--ignore-path` followed by a custom name. It uses the same syntax as `.gitignore`, including support for negation with !. diff --git a/docs/configuration.md b/docs/configuration.md index 0b3a8f8b..e3f0c649 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -44,6 +44,28 @@ node_modules/ additional-tests.sol ``` + +### Plugins and `pluginPaths` + +Solhint resolves plugins from `process.cwd()` by default and can also use extra paths from `pluginPaths`. + +Resolution paths include: + +1. `process.cwd()` +2. each `pluginPaths` entry +3. each `/node_modules` + +`pluginPaths` accepts either a string or an array: + +```json +{ + "pluginPaths": ["/some/path"], + "plugins": ["myplugin"] +} +``` + +If one plugin cannot be loaded, Solhint emits a warning and continues linting with core rules and any other valid plugins. + ### Configure linter with comments You can use comments in the source code to configure solhint in a given line or file. From 0933819a5f3861eb4809b82da6f2f97e254038bb Mon Sep 17 00:00:00 2001 From: Diego Bale <89921440+dbale-altoros@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:51:09 -0300 Subject: [PATCH 15/21] Add e2e plugin scenarios for pluginPaths resolution --- e2e/15-plugins/Contract.sol | 5 ++ .../solhint-plugin-external/index.js | 12 +++ .../solhint-plugin-external/package.json | 5 ++ .../solhint-plugin-local/index.js | 12 +++ .../solhint-plugin-local/package.json | 5 ++ .../solhint-plugin-multi-one/index.js | 12 +++ .../solhint-plugin-multi-one/package.json | 5 ++ .../solhint-plugin-multi-two/index.js | 12 +++ .../solhint-plugin-multi-two/package.json | 5 ++ e2e/test.js | 84 +++++++++++++++++++ 10 files changed, 157 insertions(+) create mode 100644 e2e/15-plugins/Contract.sol create mode 100644 e2e/15-plugins/external-project/node_modules/solhint-plugin-external/index.js create mode 100644 e2e/15-plugins/external-project/node_modules/solhint-plugin-external/package.json create mode 100644 e2e/15-plugins/node_modules/solhint-plugin-local/index.js create mode 100644 e2e/15-plugins/node_modules/solhint-plugin-local/package.json create mode 100644 e2e/15-plugins/path-one/node_modules/solhint-plugin-multi-one/index.js create mode 100644 e2e/15-plugins/path-one/node_modules/solhint-plugin-multi-one/package.json create mode 100644 e2e/15-plugins/path-two/node_modules/solhint-plugin-multi-two/index.js create mode 100644 e2e/15-plugins/path-two/node_modules/solhint-plugin-multi-two/package.json diff --git a/e2e/15-plugins/Contract.sol b/e2e/15-plugins/Contract.sol new file mode 100644 index 00000000..4fe1408d --- /dev/null +++ b/e2e/15-plugins/Contract.sol @@ -0,0 +1,5 @@ +pragma solidity ^0.4.4; + +contract C { + +} diff --git a/e2e/15-plugins/external-project/node_modules/solhint-plugin-external/index.js b/e2e/15-plugins/external-project/node_modules/solhint-plugin-external/index.js new file mode 100644 index 00000000..d87860b0 --- /dev/null +++ b/e2e/15-plugins/external-project/node_modules/solhint-plugin-external/index.js @@ -0,0 +1,12 @@ +class ExternalRule { + constructor(reporter) { + this.reporter = reporter + this.ruleId = 'external-rule' + } + + ContractDefinition(node) { + this.reporter.error(node, this.ruleId, 'External plugin rule triggered') + } +} + +module.exports = [ExternalRule] diff --git a/e2e/15-plugins/external-project/node_modules/solhint-plugin-external/package.json b/e2e/15-plugins/external-project/node_modules/solhint-plugin-external/package.json new file mode 100644 index 00000000..c10a7598 --- /dev/null +++ b/e2e/15-plugins/external-project/node_modules/solhint-plugin-external/package.json @@ -0,0 +1,5 @@ +{ + "name": "solhint-plugin-external", + "version": "1.0.0", + "main": "index.js" +} diff --git a/e2e/15-plugins/node_modules/solhint-plugin-local/index.js b/e2e/15-plugins/node_modules/solhint-plugin-local/index.js new file mode 100644 index 00000000..e17cf8a6 --- /dev/null +++ b/e2e/15-plugins/node_modules/solhint-plugin-local/index.js @@ -0,0 +1,12 @@ +class LocalRule { + constructor(reporter) { + this.reporter = reporter + this.ruleId = 'local-rule' + } + + ContractDefinition(node) { + this.reporter.error(node, this.ruleId, 'Local plugin rule triggered') + } +} + +module.exports = [LocalRule] diff --git a/e2e/15-plugins/node_modules/solhint-plugin-local/package.json b/e2e/15-plugins/node_modules/solhint-plugin-local/package.json new file mode 100644 index 00000000..b5529147 --- /dev/null +++ b/e2e/15-plugins/node_modules/solhint-plugin-local/package.json @@ -0,0 +1,5 @@ +{ + "name": "solhint-plugin-local", + "version": "1.0.0", + "main": "index.js" +} diff --git a/e2e/15-plugins/path-one/node_modules/solhint-plugin-multi-one/index.js b/e2e/15-plugins/path-one/node_modules/solhint-plugin-multi-one/index.js new file mode 100644 index 00000000..dcec2fd2 --- /dev/null +++ b/e2e/15-plugins/path-one/node_modules/solhint-plugin-multi-one/index.js @@ -0,0 +1,12 @@ +class MultiOneRule { + constructor(reporter) { + this.reporter = reporter + this.ruleId = 'multi-one-rule' + } + + ContractDefinition(node) { + this.reporter.error(node, this.ruleId, 'Multi one plugin rule triggered') + } +} + +module.exports = [MultiOneRule] diff --git a/e2e/15-plugins/path-one/node_modules/solhint-plugin-multi-one/package.json b/e2e/15-plugins/path-one/node_modules/solhint-plugin-multi-one/package.json new file mode 100644 index 00000000..b86b96f3 --- /dev/null +++ b/e2e/15-plugins/path-one/node_modules/solhint-plugin-multi-one/package.json @@ -0,0 +1,5 @@ +{ + "name": "solhint-plugin-multi-one", + "version": "1.0.0", + "main": "index.js" +} diff --git a/e2e/15-plugins/path-two/node_modules/solhint-plugin-multi-two/index.js b/e2e/15-plugins/path-two/node_modules/solhint-plugin-multi-two/index.js new file mode 100644 index 00000000..3a2bf985 --- /dev/null +++ b/e2e/15-plugins/path-two/node_modules/solhint-plugin-multi-two/index.js @@ -0,0 +1,12 @@ +class MultiTwoRule { + constructor(reporter) { + this.reporter = reporter + this.ruleId = 'multi-two-rule' + } + + ContractDefinition(node) { + this.reporter.error(node, this.ruleId, 'Multi two plugin rule triggered') + } +} + +module.exports = [MultiTwoRule] diff --git a/e2e/15-plugins/path-two/node_modules/solhint-plugin-multi-two/package.json b/e2e/15-plugins/path-two/node_modules/solhint-plugin-multi-two/package.json new file mode 100644 index 00000000..ddf50904 --- /dev/null +++ b/e2e/15-plugins/path-two/node_modules/solhint-plugin-multi-two/package.json @@ -0,0 +1,5 @@ +{ + "name": "solhint-plugin-multi-two", + "version": "1.0.0", + "main": "index.js" +} diff --git a/e2e/test.js b/e2e/test.js index 8590a4e8..7390ac27 100644 --- a/e2e/test.js +++ b/e2e/test.js @@ -463,6 +463,86 @@ describe('e2e general tests', function () { }) }) + describe('plugins', function () { + const PATH = '15-plugins' + + useFixture(PATH) + + it('should load plugin from local node_modules', function () { + writeJsonFile('.solhint.local-plugin.json', { + plugins: ['local'], + rules: { + 'local/local-rule': 'error', + }, + }) + + const { code, stdout } = shell.exec( + 'solhint --noPoster --disc -c .solhint.local-plugin.json Contract.sol' + ) + + expect(code).to.equal(EXIT_CODES.REPORTED_ERRORS) + expect(stdout).to.contain('Local plugin rule triggered') + }) + + it('should load plugin from pluginPaths (plugin under /node_modules)', function () { + writeJsonFile('.solhint.plugin-path.json', { + pluginPaths: path.join(this.testDirPath, 'external-project'), + plugins: ['external'], + rules: { + 'external/external-rule': 'error', + }, + }) + + const { code, stdout } = shell.exec( + 'solhint --noPoster --disc -c .solhint.plugin-path.json Contract.sol' + ) + + expect(code).to.equal(EXIT_CODES.REPORTED_ERRORS) + expect(stdout).to.contain('External plugin rule triggered') + }) + + it('should support multiple pluginPaths and load plugins from both', function () { + writeJsonFile('.solhint.multiple-plugin-paths.json', { + pluginPaths: [ + path.join(this.testDirPath, 'path-one'), + path.join(this.testDirPath, 'path-two'), + ], + plugins: ['multi-one', 'multi-two'], + rules: { + 'multi-one/multi-one-rule': 'error', + 'multi-two/multi-two-rule': 'error', + }, + }) + + const { code, stdout } = shell.exec( + 'solhint --noPoster --disc -c .solhint.multiple-plugin-paths.json Contract.sol' + ) + + expect(code).to.equal(EXIT_CODES.REPORTED_ERRORS) + expect(stdout).to.contain('Multi one plugin rule triggered') + expect(stdout).to.contain('Multi two plugin rule triggered') + }) + + it('should continue linting when one plugin is missing and still run core + valid plugin rules', function () { + writeJsonFile('.solhint.plugin-missing.json', { + plugins: ['missing-plugin-206', 'local'], + rules: { + 'compiler-version': 'error', + 'local/local-rule': 'error', + }, + }) + + const { code, stdout, stderr } = shell.exec( + 'solhint --noPoster --disc -c .solhint.plugin-missing.json Contract.sol' + ) + + expect(code).to.equal(EXIT_CODES.REPORTED_ERRORS) + expect(stdout).to.contain('Local plugin rule triggered') + expect(stdout).to.contain('Compiler version') + expect(stderr + stdout).to.contain('Could not load solhint-plugin-missing-plugin-206') + }) + }) + describe('shareable configs', function () { const PATH = '14-shareable-config/filesystem' let folderCounter = 1 @@ -563,6 +643,10 @@ function useFixtureFolder(ctx, dir) { shell.cd(testDirPath) } +function writeJsonFile(filePath, data) { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)) +} + function createDummyFile(fullFilePath, content = '// dummy file\npragma solidity ^0.8.0;') { const dir = path.dirname(fullFilePath) fs.mkdirSync(dir, { recursive: true }) From fb4de7963137cc2d4660e925b89e8037dccc3db6 Mon Sep 17 00:00:00 2001 From: dbale-altoros Date: Tue, 10 Mar 2026 18:07:23 -0300 Subject: [PATCH 16/21] add: mote e2e tests --- e2e/test.js | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/e2e/test.js b/e2e/test.js index 7bc3dbd5..3e6a0db6 100644 --- a/e2e/test.js +++ b/e2e/test.js @@ -462,7 +462,7 @@ describe('e2e general tests', function () { expect(stdout.trim()).to.not.contain('Skip.sol') }) }) - + describe('shareable configs', function () { const PATH = '14-shareable-config/filesystem' let folderCounter = 1 @@ -616,6 +616,86 @@ describe('e2e general tests', function () { expect(stdout).to.contain('Compiler version') expect(stderr + stdout).to.contain('Could not load solhint-plugin-missing-plugin-206') }) + +it('should load plugin rules from an extended shareable config', function () { + fs.mkdirSync('node_modules/@test/solhint-config-demo-plugin-local', { recursive: true }) + fs.writeFileSync( + 'node_modules/@test/solhint-config-demo-plugin-local/index.js', + `module.exports = { + plugins: ['local'], + rules: { + 'local/local-rule': 'error', + }, + } + ` + ) + + writeJsonFile('.solhint.extends-local-plugin.json', { + extends: ['@test/demo-plugin-local'], + }) + + const { code, stdout } = shell.exec( + 'solhint --noPoster --disc -c .solhint.extends-local-plugin.json Contract.sol' + ) + + expect(code).to.equal(EXIT_CODES.REPORTED_ERRORS) + expect(stdout).to.contain('Local plugin rule triggered') + }) + + it('should load plugin from pluginPaths defined in an extended shareable config', function () { + fs.mkdirSync('node_modules/@test/solhint-config-demo-plugin-external-path', { recursive: true }) + fs.writeFileSync( + 'node_modules/@test/solhint-config-demo-plugin-external-path/index.js', + `module.exports = { + pluginPaths: ['external-project'], + plugins: ['external'], + rules: { + 'external/external-rule': 'error', + }, + } + ` + ) + + writeJsonFile('.solhint.extends-external-plugin.json', { + extends: ['@test/demo-plugin-external-path'], + }) + + const { code, stdout } = shell.exec( + 'solhint --noPoster --disc -c .solhint.extends-external-plugin.json Contract.sol' + ) + + expect(code).to.equal(EXIT_CODES.REPORTED_ERRORS) + expect(stdout).to.contain('External plugin rule triggered') + }) + + it('should load plugin from local config when using extends shareable config', function () { + fs.mkdirSync('node_modules/@test/solhint-config-demo-core-only', { recursive: true }) + fs.writeFileSync( + 'node_modules/@test/solhint-config-demo-core-only/index.js', + `module.exports = { + rules: { + 'compiler-version': ['error', '^0.8.24'], + }, + } + ` + ) + + writeJsonFile('.solhint.extends-plus-local-plugin.json', { + extends: ['@test/demo-core-only'], + plugins: ['local'], + rules: { + 'local/local-rule': 'error', + }, + }) + + const { code, stdout } = shell.exec( + 'solhint --noPoster --disc -c .solhint.extends-plus-local-plugin.json Contract.sol' + ) + + expect(code).to.equal(EXIT_CODES.REPORTED_ERRORS) + expect(stdout).to.contain('Compiler version') + expect(stdout).to.contain('Local plugin rule triggered') + }) }) }) From 4f74aca4bdfa336a007c7785643e7d17f0abae13 Mon Sep 17 00:00:00 2001 From: dbale-altoros Date: Tue, 17 Mar 2026 15:11:42 -0300 Subject: [PATCH 17/21] add changelog --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f28f05e..a6d1d1d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## [6.0.4] - xxxxxx +🛠️ Fix: `natspec` rule no longer flags unnamed parameters, which Solidity prohibits documenting with @param (#749) + +🛠️ Fix: `natspec` rule and `import-path-check` rules related issues (#750) + +🛠️ Fix: scoped package names now supported for shareable configs (#741) + +🛠️ Fix: misc minor issues and general polish (#739) +

+ +🧱 Enhancement: added `pluginPaths` config option for resolving plugins from custom locations. +Supports editor integrations y external project setups. Failed plugins emit warnings instead of crashing (#751) +

+ +🧹 Chore: bump lodash from 4.17.21 to 4.17.23 — includes prototype pollution fix in baseUnset (#740) + +🧹 Chore: update LICENSE copyright year to 2026 (thanks xiaobei0715!!) (#745) +

+ +✨🛡️ Kudos to our contributors! 🛡️✨ + +- [xiaobei0715](https://github.com/xiaobei0715) + + ## [6.0.3] - 2026-01-20 🛠️ `Fix`: removed unused files, normalized schema for validation, load-rules, base-checker and validator improvements From a028c181ed8c86aa3bbee732f3636da24a31ad11 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 19:09:00 +0000 Subject: [PATCH 18/21] fix: update code for ajv v8 compatibility - Remove ajv/lib/refs/json-schema-draft-04.json import (not available in v8) - Remove deprecated jsonPointers option from base-checker - Replace error.dataPath with error.instancePath in config-validator - Upgrade ajv-errors from v1 to v3 (required for ajv v8) https://claude.ai/code/session_019Fh9mDiLK59CAXGBM6LCfW --- lib/common/ajv.js | 8 +------- lib/config/config-validator.js | 9 +++++---- lib/rules/base-checker.js | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- 5 files changed, 13 insertions(+), 18 deletions(-) diff --git a/lib/common/ajv.js b/lib/common/ajv.js index fbe02b83..5aef59e1 100644 --- a/lib/common/ajv.js +++ b/lib/common/ajv.js @@ -1,15 +1,9 @@ const Ajv = require('ajv') -const metaSchema = require('ajv/lib/refs/json-schema-draft-04.json') const ajv = new Ajv({ - meta: false, validateSchema: false, - missingRefs: 'ignore', verbose: true, - schemaId: 'auto', + allErrors: true, }) -ajv.addMetaSchema(metaSchema) -ajv._opts.defaultMeta = metaSchema.id - module.exports = ajv diff --git a/lib/config/config-validator.js b/lib/config/config-validator.js index fc60c899..a9ccb821 100644 --- a/lib/config/config-validator.js +++ b/lib/config/config-validator.js @@ -16,15 +16,16 @@ const defaultSchemaValueForRules = Object.freeze({ const formatErrors = (errors) => errors .map((error) => { + const dataPath = error.instancePath || '' if (error.keyword === 'additionalProperties') { - const formattedPropertyPath = error.dataPath.length - ? `${error.dataPath.slice(1)}.${error.params.additionalProperty}` + const formattedPropertyPath = dataPath.length + ? `${dataPath.slice(1)}.${error.params.additionalProperty}` : error.params.additionalProperty return `Unexpected top-level property "${formattedPropertyPath}"` } if (error.keyword === 'type') { - const formattedField = error.dataPath.slice(1) + const formattedField = dataPath.slice(1) const formattedExpectedType = Array.isArray(error.schema) ? error.schema.join('/') : error.schema @@ -33,7 +34,7 @@ const formatErrors = (errors) => return `Property "${formattedField}" is the wrong type (expected ${formattedExpectedType} but got \`${formattedValue}\`)` } - const field = error.dataPath[0] === '.' ? error.dataPath.slice(1) : error.dataPath + const field = dataPath[0] === '/' ? dataPath.slice(1) : dataPath return `"${field}" ${error.message}. Value: ${JSON.stringify(error.data)}` }) diff --git a/lib/rules/base-checker.js b/lib/rules/base-checker.js index 0352673a..c17e78f3 100644 --- a/lib/rules/base-checker.js +++ b/lib/rules/base-checker.js @@ -77,7 +77,7 @@ class BaseChecker { } } -BaseChecker.ajv = new Ajv({ allErrors: true, jsonPointers: true }) +BaseChecker.ajv = new Ajv({ allErrors: true }) ajvErrors(BaseChecker.ajv) // keep support of errorMessage BaseChecker.validators = new Map() diff --git a/package-lock.json b/package-lock.json index 82cbc84a..2d146f3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@solidity-parser/parser": "^0.20.2", "ajv": "^8.18.0", - "ajv-errors": "^1.0.1", + "ajv-errors": "^3.0.0", "ast-parents": "^0.0.1", "better-ajv-errors": "^2.0.2", "chalk": "^4.1.2", @@ -950,12 +950,12 @@ } }, "node_modules/ajv-errors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz", - "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", + "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", "license": "MIT", "peerDependencies": { - "ajv": ">=5.0.0" + "ajv": "^8.0.1" } }, "node_modules/ansi-colors": { diff --git a/package.json b/package.json index 13170fdb..719c1a27 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "dependencies": { "@solidity-parser/parser": "^0.20.2", "ajv": "^8.18.0", - "ajv-errors": "^1.0.1", + "ajv-errors": "^3.0.0", "ast-parents": "^0.0.1", "better-ajv-errors": "^2.0.2", "chalk": "^4.1.2", From 89f871cd12d084b253581b2db32f50a33a559d40 Mon Sep 17 00:00:00 2001 From: dbale-altoros Date: Tue, 17 Mar 2026 16:14:53 -0300 Subject: [PATCH 19/21] merged with claude bump --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6d1d1d5..c65306fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Supports editor integrations y external project setups. Failed plugins emit warnings instead of crashing (#751)

-🧹 Chore: bump lodash from 4.17.21 to 4.17.23 — includes prototype pollution fix in baseUnset (#740) +🧹 Chore: bump ajv to 8.18.0 🧹 Chore: update LICENSE copyright year to 2026 (thanks xiaobei0715!!) (#745)

From 28325517fa2b0a6ca0e36eb9acc3a2b83994612c Mon Sep 17 00:00:00 2001 From: dbale-altoros Date: Tue, 17 Mar 2026 16:35:08 -0300 Subject: [PATCH 20/21] prerelease v610 --- CHANGELOG.md | 6 ++++-- docker/Dockerfile | 2 +- package.json | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c65306fb..bb92ae81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## [6.0.4] - xxxxxx +## [6.1.0] - 2026-03-17 🛠️ Fix: `natspec` rule no longer flags unnamed parameters, which Solidity prohibits documenting with @param (#749) 🛠️ Fix: `natspec` rule and `import-path-check` rules related issues (#750) @@ -9,11 +9,13 @@

🧱 Enhancement: added `pluginPaths` config option for resolving plugins from custom locations. -Supports editor integrations y external project setups. Failed plugins emit warnings instead of crashing (#751) +Supports editor integrations and external project setups. Failed plugins emit warnings instead of crashing (#751)

🧹 Chore: bump ajv to 8.18.0 +🧹 Chore: bump minimatch to 10.2.4 + 🧹 Chore: update LICENSE copyright year to 2026 (thanks xiaobei0715!!) (#745)

diff --git a/docker/Dockerfile b/docker/Dockerfile index fb8f8a7d..a04b948b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ FROM node:20-alpine LABEL maintainer="diego.bale@protofire.io" -ENV VERSION=6.0.3 +ENV VERSION=6.1.0 RUN npm install -g solhint@"$VERSION" \ No newline at end of file diff --git a/package.json b/package.json index 719c1a27..d340cb01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solhint", - "version": "6.0.3", + "version": "6.1.0", "description": "Solidity Code Linter", "main": "lib/index.js", "keywords": [ From 41b31b6a3fb3c53e9ae2f5a6ae9cff48a1c5c539 Mon Sep 17 00:00:00 2001 From: dbale-altoros Date: Tue, 17 Mar 2026 16:42:34 -0300 Subject: [PATCH 21/21] prerelease v610 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb92ae81..1c7281cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ Supports editor integrations and external project setups. Failed plugins emit wa 🧹 Chore: bump minimatch to 10.2.4 +🧹 Chore: bump loadash to 4.17.23 + 🧹 Chore: update LICENSE copyright year to 2026 (thanks xiaobei0715!!) (#745)