diff --git a/crytic_compile/utils/naming.py b/crytic_compile/utils/naming.py index e4711f44..efb26013 100644 --- a/crytic_compile/utils/naming.py +++ b/crytic_compile/utils/naming.py @@ -118,11 +118,65 @@ def _verify_filename_existence(filename: Path, cwd: Path) -> Path: break if not filename.exists(): - raise InvalidCompilation(f"Unknown file: {filename}") + raise InvalidCompilation(_unknown_file_message(filename, cwd)) return filename +def _unknown_file_message(filename: Path, cwd: Path) -> str: + """Build an actionable error message for a path that could not be resolved. + + Includes hints to verify the path exists and, when the import resembles an + npm/yarn dependency (e.g. ``@openzeppelin/...``), to install project + dependencies. + + Args: + filename (Path): the unresolved filename + cwd (Path): the working directory used during resolution + + Returns: + str: a multi-line error message + """ + lines = [ + f"Unknown file: {filename}", + f" - Verify the path exists (e.g. `ls {filename}`).", + ] + if _looks_like_npm_import(filename): + lines.append( + f" - If this is an npm/yarn dependency, install project " + f"dependencies first (e.g. `npm install` or `yarn install` in `{cwd}`)." + ) + return "\n".join(lines) + + +def _looks_like_npm_import(filename: Path) -> bool: + """Heuristic: is ``filename`` likely an npm/yarn dependency import? + + Treats scoped (``@org/pkg/...``) and bare-name (``pkg/...``) paths with + multiple segments as package imports. Excludes absolute paths, paths that + start with ``.`` or ``..``, and the common Solidity project source roots + (``contracts``, ``src``, ``lib``, ``test``, ``script``). + + Args: + filename (Path): filename to classify + + Returns: + bool: True if the path resembles a package import + """ + if filename.is_absolute(): + return False + parts = filename.parts + if len(parts) < 2: + return False + first = parts[0] + if first.startswith("."): + return False + if first in {"contracts", "src", "lib", "test", "tests", "script"}: + return False + # @scope/package or bare package name (no extension/dot in first segment) + return first.startswith("@") or "." not in first + + def convert_filename( used_filename: str | Path, relative_to_short: Callable[[Path], Path], diff --git a/tests/test_naming.py b/tests/test_naming.py new file mode 100644 index 00000000..2be94b1f --- /dev/null +++ b/tests/test_naming.py @@ -0,0 +1,73 @@ +"""Tests for filename resolution error messages.""" + +from pathlib import Path + +import pytest + +from crytic_compile.platform.exceptions import InvalidCompilation +from crytic_compile.utils.naming import ( + _looks_like_npm_import, + _unknown_file_message, + _verify_filename_existence, +) + + +def test_unknown_file_error_includes_path_and_ls_hint(tmp_path: Path) -> None: + """Error includes the requested filename and an `ls` hint.""" + missing = Path("nonexistent/Foo.sol") + with pytest.raises(InvalidCompilation) as excinfo: + _verify_filename_existence(missing, tmp_path) + msg = str(excinfo.value) + assert "Unknown file" in msg + assert str(missing) in msg + assert "ls " in msg + + +def test_unknown_file_error_npm_scoped_hint(tmp_path: Path) -> None: + """Scoped package imports surface the npm/yarn install hint.""" + missing = Path("@openzeppelin/contracts/token/ERC20/ERC20.sol") + with pytest.raises(InvalidCompilation) as excinfo: + _verify_filename_existence(missing, tmp_path) + msg = str(excinfo.value) + assert "npm install" in msg + assert "yarn install" in msg + assert str(tmp_path) in msg + + +def test_unknown_file_error_bare_package_hint(tmp_path: Path) -> None: + """Bare-name imports (e.g. `solmate/...`) surface the install hint.""" + missing = Path("solmate/src/tokens/ERC20.sol") + msg = _unknown_file_message(missing, tmp_path) + assert "npm install" in msg + + +def test_unknown_file_error_project_dir_no_hint(tmp_path: Path) -> None: + """Local project source paths (`contracts/...`) do not surface npm hint.""" + msg = _unknown_file_message(Path("contracts/Foo.sol"), tmp_path) + assert "npm install" not in msg + assert "Unknown file" in msg + + +def test_unknown_file_error_relative_no_hint(tmp_path: Path) -> None: + """Explicit relative paths (`./Foo.sol`) do not surface npm hint.""" + msg = _unknown_file_message(Path("./Foo.sol"), tmp_path) + assert "npm install" not in msg + + +@pytest.mark.parametrize( + ("path", "expected"), + [ + ("@openzeppelin/contracts/token/ERC20.sol", True), + ("solmate/src/tokens/ERC20.sol", True), + ("contracts/Foo.sol", False), + ("src/Foo.sol", False), + ("lib/forge-std/src/Test.sol", False), + ("./Foo.sol", False), + ("../Foo.sol", False), + ("Foo.sol", False), + ("/abs/path/Foo.sol", False), + ], +) +def test_looks_like_npm_import(path: str, expected: bool) -> None: + """Classifier covers scoped/bare/project/relative/absolute cases.""" + assert _looks_like_npm_import(Path(path)) is expected