diff --git a/slither/__main__.py b/slither/__main__.py index db0c543f05..e48b146987 100644 --- a/slither/__main__.py +++ b/slither/__main__.py @@ -54,6 +54,8 @@ logging.basicConfig() logger = logging.getLogger("Slither") +DEFAULT_EXCLUDE_TEST_PATHS = r"(?i)(^|/)(tests?|mocks?)(/|$)|(^|/)(test|mock)[^/]*\.sol$" + ################################################################################### ################################################################################### @@ -315,6 +317,16 @@ def parse_filter_paths(args: argparse.Namespace, filter_path: bool) -> list[str] return [] +def apply_exclude_test_filter(args: argparse.Namespace) -> None: + if not args.exclude_test: + return + + if args.include_paths: + raise ValueError("Error: --exclude-test cannot be used with --include-paths") + + args.filter_paths.append(DEFAULT_EXCLUDE_TEST_PATHS) + + def parse_args( detector_classes: list[type[AbstractDetector]], printer_classes: list[type[AbstractPrinter]], @@ -442,6 +454,13 @@ def parse_args( default=defaults_flag_in_config["exclude_high"], ) + group_detector.add_argument( + "--exclude-test", + help="Exclude detector results matching test or mock file paths", + action="store_true", + default=defaults_flag_in_config["exclude_test"], + ) + group_detector.add_argument( "--exclude-location", help="Exclude file location (filename and lines) from detector messages", @@ -718,6 +737,7 @@ def parse_args( args.filter_paths = parse_filter_paths(args, True) args.include_paths = parse_filter_paths(args, False) + apply_exclude_test_filter(args) # Verify our json-type output is valid args.json_types = set(args.json_types.split(",")) # type:ignore diff --git a/slither/utils/command_line.py b/slither/utils/command_line.py index 3242e3aeb1..66ad7cb29a 100644 --- a/slither/utils/command_line.py +++ b/slither/utils/command_line.py @@ -55,6 +55,7 @@ class FailOnLevel(enum.Enum): "exclude_low": False, "exclude_medium": False, "exclude_high": False, + "exclude_test": False, "exclude_location": False, "fail_on": FailOnLevel.PEDANTIC, "json": None, diff --git a/tests/unit/utils/test_command_line.py b/tests/unit/utils/test_command_line.py new file mode 100644 index 0000000000..3446d078d2 --- /dev/null +++ b/tests/unit/utils/test_command_line.py @@ -0,0 +1,56 @@ +from argparse import Namespace +import re + +import pytest + +from slither.__main__ import DEFAULT_EXCLUDE_TEST_PATHS, apply_exclude_test_filter + + +def test_apply_exclude_test_filter_adds_default_pattern(): + args = Namespace(exclude_test=True, filter_paths=[], include_paths=[]) + + apply_exclude_test_filter(args) + + assert args.filter_paths == [DEFAULT_EXCLUDE_TEST_PATHS] + + +def test_apply_exclude_test_filter_preserves_existing_filters(): + args = Namespace(exclude_test=True, filter_paths=["vendor"], include_paths=[]) + + apply_exclude_test_filter(args) + + assert args.filter_paths == ["vendor", DEFAULT_EXCLUDE_TEST_PATHS] + + +def test_apply_exclude_test_filter_rejects_include_paths(): + args = Namespace(exclude_test=True, filter_paths=[], include_paths=["contracts"]) + + with pytest.raises(ValueError, match="--exclude-test cannot be used with --include-paths"): + apply_exclude_test_filter(args) + + +def test_apply_exclude_test_filter_noops_when_disabled(): + args = Namespace(exclude_test=False, filter_paths=["vendor"], include_paths=[]) + + apply_exclude_test_filter(args) + + assert args.filter_paths == ["vendor"] + + +@pytest.mark.parametrize( + "path", + [ + "/repo/test/Token.sol", + "/repo/tests/Token.sol", + "/repo/mock/Token.sol", + "/repo/mocks/Token.sol", + "/repo/contracts/TestToken.sol", + "/repo/contracts/MockToken.sol", + ], +) +def test_exclude_test_default_pattern_matches_test_and_mock_paths(path): + assert re.search(DEFAULT_EXCLUDE_TEST_PATHS, path) + + +def test_exclude_test_default_pattern_keeps_regular_contract_paths(): + assert not re.search(DEFAULT_EXCLUDE_TEST_PATHS, "/repo/contracts/Token.sol")