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) + }) + }) })