Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions certora_autosetup/utils/project_dir.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
that ran. See ``find_build_config_dir``.
"""

import json
import os
import tomllib
from pathlib import Path
Expand Down Expand Up @@ -85,11 +86,56 @@ def _artifact_dir_of(config_dir: Path) -> Optional[Path]:
)


def _declared_sources(artifacts: Path, limit: int = 20) -> list[str]:
"""Source paths a sample of *artifacts* say they were compiled from.

Foundry records them as the keys of ``metadata.settings.compilationTarget``; Hardhat as

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. We support more build systems than just hardhat and foundry, so perhaps we should handle all of them also here.
  2. it would be better to have this as an abstract method implemented by the particular build managers

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude answers: Done, both points.

``sourceName``. Both are relative to the project the build ran in, which is the fact this
module needs and cannot get from the directory layout alone.
"""
found: list[str] = []
for json_file in artifacts.rglob("*.json"):
try:
with json_file.open() as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
continue
if not isinstance(data, dict):
continue
target = (data.get("metadata") or {}).get("settings", {}).get("compilationTarget")
if isinstance(target, dict) and target:
found.extend(str(k) for k in target)
elif isinstance(data.get("sourceName"), str):
found.append(data["sourceName"])
if len(found) >= limit:
break
return found


def _artifacts_belong_to(config_dir: Path, artifacts: Path) -> bool:
"""Whether the artifacts in *artifacts* were written by the project at *config_dir*.

Two configs can name the same physical artifact directory — a root ``foundry.toml`` with
``out = 'contracts/out'`` next to ``contracts/foundry.toml`` with ``out = 'out'`` — and only
one of them ran. The artifacts say which: their recorded source paths resolve against the
project that produced them, so if none of them exists under *config_dir*, these are somebody
else's artifacts and this directory is the wrong frame to read them in.

Artifacts that record no source path at all (older Foundry, metadata stripped) answer True:
absence of evidence should leave the previous behaviour alone.
"""
declared = _declared_sources(artifacts)
if not declared:
return True
return any((config_dir / rel).exists() for rel in declared)


def find_build_config_dir(contract_path: Path, root: Path) -> Path:
"""Return the directory whose build system actually produced *contract_path*'s artifacts.

Walks up from the contract's own directory to *root*, and returns the nearest ancestor
that both holds a build config **and** has its artifact directory on disk. Falling back
Walks up from the contract's own directory to *root*, and returns the nearest ancestor that
holds a build config, has its artifact directory on disk, and whose artifacts record source
paths that resolve inside it. Falling back
to *root* when nothing qualifies is deliberate: a build config alone does not mean that
project is the one that got built. Monorepos routinely vendor per-package ``foundry.toml``
files under ``modules/`` or ``lib/`` while the root config builds the whole tree into a
Expand Down Expand Up @@ -121,7 +167,7 @@ def find_build_config_dir(contract_path: Path, root: Path) -> Path:
current = absolute_contract.resolve().parent
while True:
artifacts = _artifact_dir_of(current)
if artifacts is not None and artifacts.is_dir():
if artifacts is not None and artifacts.is_dir() and _artifacts_belong_to(current, artifacts):
return current
if current == root:
return root
Expand Down
54 changes: 54 additions & 0 deletions tests/test_project_dir.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,60 @@ def test_no_artifacts_anywhere_falls_back_to_root(tmp_path: Path) -> None:
assert find_build_config_dir(contract, tmp_path) == tmp_path.resolve()


def _artifact(out_dir: Path, source: str, name: str) -> None:
"""Write a Foundry artifact that records the source it was compiled from."""
d = out_dir / Path(source).name
d.mkdir(parents=True, exist_ok=True)
(d / f"{name}.json").write_text(
'{"metadata": {"settings": {"compilationTarget": {"%s": "%s"}}}}' % (source, name)
)


def test_shared_out_dir_resolves_to_the_project_that_wrote_it(tmp_path: Path) -> None:
# Two configs naming the same physical artifact directory: the root builds the tree with
# out = 'pkg/out', and pkg/ carries its own foundry.toml with the default out = 'out'. Only
# the root ran, and its artifacts say so by recording paths relative to the root, so pkg/ is
# the wrong frame to read them in even though it has a config and a populated out/ beside it.
(tmp_path / "foundry.toml").write_text("[profile.default]\nsrc = 'pkg/src'\nout = 'pkg/out'\n")
pkg = tmp_path / "pkg"
(pkg / "src").mkdir(parents=True)
(pkg / "foundry.toml").write_text("[profile.default]\nsrc = 'src'\nout = 'out'\n")
contract = pkg / "src" / "Widget.sol"
contract.write_text("contract Widget {}")
_artifact(pkg / "out", "pkg/src/Widget.sol", "Widget")

assert find_build_config_dir(contract, tmp_path) == tmp_path.resolve()


def test_nested_project_that_wrote_its_own_artifacts_still_wins(tmp_path: Path) -> None:
# The counterpart: the nested project really did build, and its artifacts record paths
# relative to itself. Anchoring there is right, and the shared-out check must not undo it.
(tmp_path / "package.json").write_text("{}")
pkg = tmp_path / "pkg"
(pkg / "src").mkdir(parents=True)
(pkg / "foundry.toml").write_text("[profile.default]\n")
contract = pkg / "src" / "Widget.sol"
contract.write_text("contract Widget {}")
_artifact(pkg / "out", "src/Widget.sol", "Widget")

assert find_build_config_dir(contract, tmp_path) == pkg.resolve()


def test_artifacts_without_recorded_sources_keep_the_nearest_built_config(tmp_path: Path) -> None:
# Older Foundry, or metadata stripped: nothing says which project wrote these, and absence
# of evidence should not move the anchor.
(tmp_path / "package.json").write_text("{}")
pkg = tmp_path / "pkg"
(pkg / "src").mkdir(parents=True)
(pkg / "foundry.toml").write_text("[profile.default]\n")
contract = pkg / "src" / "Widget.sol"
contract.write_text("contract Widget {}")
(pkg / "out" / "Widget.sol").mkdir(parents=True)
(pkg / "out" / "Widget.sol" / "Widget.json").write_text('{"abi": []}')

assert find_build_config_dir(contract, tmp_path) == pkg.resolve()


def test_honors_a_custom_foundry_out_dir(tmp_path: Path) -> None:
project = tmp_path / "sub"
(project / "src").mkdir(parents=True)
Expand Down
Loading