From 14599298d8a96cf3489e1e38d94d5e8481632799 Mon Sep 17 00:00:00 2001 From: cats2101 Date: Wed, 29 Apr 2026 00:09:10 +0000 Subject: [PATCH] fix: surface stale build artifacts hint on cache miss `get_line_from_offset` and `get_global_offset_from_line` could raise a bare `KeyError` when the cached source map fell outside the file on disk, which typically happens when build artifacts get out of sync with the source tree. Wrap the lookup in a try/except that re-raises `InvalidCompilation` with a platform-specific clean recommendation (e.g. `forge clean`, `npx hardhat clean`). Closes #265 --- crytic_compile/crytic_compile.py | 52 +++++++++++++- tests/test_stale_cache_hint.py | 115 +++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 tests/test_stale_cache_hint.py diff --git a/crytic_compile/crytic_compile.py b/crytic_compile/crytic_compile.py index 01ee6295..433a4c58 100644 --- a/crytic_compile/crytic_compile.py +++ b/crytic_compile/crytic_compile.py @@ -25,6 +25,7 @@ from crytic_compile.platform import all_platforms from crytic_compile.platform.abstract_platform import AbstractPlatform from crytic_compile.platform.all_export import PLATFORMS_EXPORT +from crytic_compile.platform.exceptions import InvalidCompilation from crytic_compile.platform.solc import Solc from crytic_compile.platform.solc_standard_json import SolcStandardJson from crytic_compile.platform.standard import export_to_standard @@ -41,6 +42,39 @@ logging.basicConfig() +_PLATFORM_CLEAN_HINTS: dict[str, str] = { + "Brownie": "brownie compile --all", + "Buidler": "npx buidler clean", + "Dapp": "dapp clean", + "Embark": "embark reset", + "Etherlime": "etherlime compile --runs 200 --solcVersion 0.5.16 --buildDirectory ./build", + "Foundry": "forge clean", + "Hardhat": "npx hardhat clean", + "Truffle": "npx truffle compile --all", + "Waffle": "rm -rf build", +} + + +def _stale_cache_hint(file: "Filename", platform: AbstractPlatform | None) -> str: + """Build a `clean and rebuild` hint for stale build artifacts. + + Args: + file (Filename): source file with a mismatched cache. + platform (Optional[AbstractPlatform]): underlying build platform, if any. + + Returns: + str: human-readable suggestion mentioning the platform-specific command. + """ + base = ( + f"Source map for '{file.absolute}' falls outside the cached source. " + "Build artifacts are likely stale relative to the source on disk." + ) + command = _PLATFORM_CLEAN_HINTS.get(platform.NAME) if platform is not None else None + if command: + return f"{base} Try `{command}` and recompile." + return f"{base} Try removing the build directory and recompiling." + + def get_platforms() -> list[type[AbstractPlatform]]: """Return the available platforms classes in order of preference @@ -361,6 +395,10 @@ def get_line_from_offset(self, filename: Filename | str, offset: int) -> tuple[i Returns: Tuple[int, int]: (line, line offset) + + Raises: + InvalidCompilation: if the offset is outside the cached source range, + which usually means the build artifacts are stale. """ if isinstance(filename, str): file = self.filename_lookup(filename) @@ -370,7 +408,10 @@ def get_line_from_offset(self, filename: Filename | str, offset: int) -> tuple[i self._get_cached_offset_to_line(file) lines_delimiters = self._cached_offset_to_line[file] - return lines_delimiters[offset] + try: + return lines_delimiters[offset] + except KeyError as exc: + raise InvalidCompilation(_stale_cache_hint(file, self._platform)) from exc def get_global_offset_from_line(self, filename: Filename | str, line: int) -> int: """Return the global offset from a given line @@ -381,6 +422,10 @@ def get_global_offset_from_line(self, filename: Filename | str, line: int) -> in Returns: int: global offset + + Raises: + InvalidCompilation: if the line is outside the cached source range, + which usually means the build artifacts are stale. """ if isinstance(filename, str): file = self.filename_lookup(filename) @@ -389,7 +434,10 @@ def get_global_offset_from_line(self, filename: Filename | str, line: int) -> in if file not in self._cached_line_to_offset: self._get_cached_offset_to_line(file) - return self._cached_line_to_offset[file][line] + try: + return self._cached_line_to_offset[file][line] + except KeyError as exc: + raise InvalidCompilation(_stale_cache_hint(file, self._platform)) from exc def _get_cached_line_to_code(self, file: Filename) -> None: """Compute the cached lines diff --git a/tests/test_stale_cache_hint.py b/tests/test_stale_cache_hint.py new file mode 100644 index 00000000..26a33067 --- /dev/null +++ b/tests/test_stale_cache_hint.py @@ -0,0 +1,115 @@ +"""Tests for stale-cache hints in `get_line_from_offset` / `get_global_offset_from_line`.""" + +from pathlib import Path + +import pytest + +from crytic_compile import CryticCompile +from crytic_compile.crytic_compile import _PLATFORM_CLEAN_HINTS, _stale_cache_hint +from crytic_compile.platform import Type +from crytic_compile.platform.abstract_platform import AbstractPlatform +from crytic_compile.platform.exceptions import InvalidCompilation +from crytic_compile.utils.naming import Filename + + +class _StubPlatform(AbstractPlatform): + """Minimal `AbstractPlatform` that performs no compilation.""" + + NAME = "Hardhat" + PROJECT_URL = "https://example.invalid" + TYPE = Type.HARDHAT + + def compile(self, crytic_compile: "CryticCompile", **kwargs: str) -> None: + return + + def clean(self, **kwargs: str) -> None: + return + + @staticmethod + def is_supported(target: str, **kwargs: str) -> bool: + return False + + def is_dependency(self, path: str) -> bool: + return False + + def _guessed_tests(self) -> list[str]: + return [] + + +def _make_filename(tmp_path: Path) -> Filename: + """Build a `Filename` pointing at an existing on-disk file.""" + src = tmp_path / "Foo.sol" + src.write_text("contract C{}\n", encoding="utf-8") + return Filename( + absolute=str(src), + used=str(src), + relative=str(src), + short=src.name, + ) + + +def _make_crytic_compile(tmp_path: Path) -> tuple[CryticCompile, Filename]: + """Build a `CryticCompile` backed by a stub platform and a single source file.""" + filename = _make_filename(tmp_path) + crytic = CryticCompile(_StubPlatform(str(tmp_path))) + crytic.src_content = {filename.absolute: Path(filename.absolute).read_text(encoding="utf-8")} + return crytic, filename + + +def test_stale_cache_hint_uses_known_platform_command(tmp_path: Path) -> None: + """Hint surfaces the platform-specific clean command when the platform is recognized.""" + + class _Platform: + NAME = "Hardhat" + + file = _make_filename(tmp_path) + msg = _stale_cache_hint(file, _Platform()) + assert "stale" in msg + assert str(file.absolute) in msg + assert _PLATFORM_CLEAN_HINTS["Hardhat"] in msg + + +def test_stale_cache_hint_falls_back_for_unknown_platform(tmp_path: Path) -> None: + """Unknown platform names fall back to a generic recommendation.""" + + class _Platform: + NAME = "MyCustomPlatform" + + file = _make_filename(tmp_path) + msg = _stale_cache_hint(file, _Platform()) + assert "build directory" in msg + for command in _PLATFORM_CLEAN_HINTS.values(): + assert command not in msg + + +def test_stale_cache_hint_handles_missing_platform(tmp_path: Path) -> None: + """`None` platform still yields a useful message.""" + file = _make_filename(tmp_path) + msg = _stale_cache_hint(file, None) + assert "stale" in msg + assert "build directory" in msg + + +def test_get_line_from_offset_raises_invalid_compilation(tmp_path: Path) -> None: + """Out-of-range offset raises `InvalidCompilation` instead of bare `KeyError`.""" + crytic, filename = _make_crytic_compile(tmp_path) + + line, _ = crytic.get_line_from_offset(filename, 0) + assert line == 1 + + with pytest.raises(InvalidCompilation) as excinfo: + crytic.get_line_from_offset(filename, 10**9) + assert "stale" in str(excinfo.value) + assert _PLATFORM_CLEAN_HINTS["Hardhat"] in str(excinfo.value) + + +def test_get_global_offset_from_line_raises_invalid_compilation(tmp_path: Path) -> None: + """Out-of-range line raises `InvalidCompilation` with a clean-command hint.""" + crytic, filename = _make_crytic_compile(tmp_path) + + assert crytic.get_global_offset_from_line(filename, 1) == 0 + + with pytest.raises(InvalidCompilation) as excinfo: + crytic.get_global_offset_from_line(filename, 10**9) + assert "stale" in str(excinfo.value) + assert _PLATFORM_CLEAN_HINTS["Hardhat"] in str(excinfo.value)