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..0d8a710c 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,11 @@ _temp/
.env
**/.DS_Store
solhint*.tgz
+
+# Exception on E2E fixtures' node_modules (they are part of the test data)
+# only allow useful test files in those node_modules
+!e2e/**/node_modules/**/*.js
+!e2e/**/node_modules/**/*.json
+
+# allow test plugins inside e2e fixtures
+!e2e/15-plugins/**
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f28f05e..1c7281cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,31 @@
+## [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)
+
+🛠️ 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 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: bump loadash to 4.17.23
+
+🧹 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
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
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/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/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.
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/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
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/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/.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/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/.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/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/.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/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/.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/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/.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/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/.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/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' } }
+
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/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 f923e235..3e6a0db6 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,241 @@ 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"')
+ })
+ })
+
+ 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')
+ })
+
+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')
+ })
+ })
})
function useFixture(dir) {
@@ -478,16 +713,20 @@ 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)
}
+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 })
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-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/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/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/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/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/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/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/package-lock.json b/package-lock.json
index 96a72aa6..2d146f3e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,17 +1,17 @@
{
"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",
- "ajv": "^6.12.6",
- "ajv-errors": "^1.0.1",
+ "ajv": "^8.18.0",
+ "ajv-errors": "^3.0.0",
"ast-parents": "^0.0.1",
"better-ajv-errors": "^2.0.2",
"chalk": "^4.1.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",
@@ -443,29 +467,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",
@@ -933,15 +934,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",
@@ -949,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": {
@@ -2379,6 +2380,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 +2498,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": {
@@ -2914,9 +2940,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"
@@ -4015,9 +4041,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": {
@@ -4119,9 +4145,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": {
@@ -4256,9 +4282,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 +4370,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": {
@@ -5208,6 +5234,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"
@@ -5507,6 +5534,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 +5599,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"
@@ -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..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": [
@@ -41,8 +41,8 @@
"license": "MIT",
"dependencies": {
"@solidity-parser/parser": "^0.20.2",
- "ajv": "^6.12.6",
- "ajv-errors": "^1.0.1",
+ "ajv": "^8.18.0",
+ "ajv-errors": "^3.0.0",
"ast-parents": "^0.0.1",
"better-ajv-errors": "^2.0.2",
"chalk": "^4.1.2",
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')
+ })
+ })
})
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)
+ })
+})
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))
+ })
+})
diff --git a/test/rules/best-practices/use-natspec.js b/test/rules/best-practices/use-natspec.js
index bf1a3f2f..81cddcc4 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,34 @@ 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)
+ })
+ })
})