Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
17 changes: 17 additions & 0 deletions certora_autosetup/build_systems/foundry.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,23 @@ def holds_artifacts(artifacts_dir: Path) -> bool:
child.is_dir() and child.name.endswith(".sol") for child in artifacts_dir.iterdir()
)

@staticmethod
def recorded_source(artifact: dict) -> Optional[str]:
"""Foundry records it under `metadata.settings.compilationTarget`, as the single key
of a one-entry `{source: ContractName}` map, relative to the project. More than one
entry means the artifact covers several sources and names none of them, so it says
nothing about which project wrote it."""
metadata = artifact.get("metadata")
if not isinstance(metadata, dict):
return None
settings = metadata.get("settings")
if not isinstance(settings, dict):
return None
target = settings.get("compilationTarget")
if not isinstance(target, dict) or len(target) != 1:
return None
return str(next(iter(target)))

def filter_artifacts(self, artifacts_dir: Path) -> List[Path]:
"""
Filter Foundry artifacts - all .json files except those in build-info/ directories.
Expand Down
10 changes: 10 additions & 0 deletions certora_autosetup/build_systems/hardhat.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,16 @@ def holds_artifacts(artifacts_dir: Path) -> bool:
return (any((artifacts_dir / "contracts").rglob("*.json"))
or any((artifacts_dir / "build-info").glob("*.json")))

@staticmethod
def recorded_source(artifact: dict) -> Optional[str]:
"""Hardhat records it as a project-relative `sourceName`. The `_format` stamp is what
separates a real artifact from the `.dbg.json` sidecars and the solc standard-json
under `build-info/`, which sit in the same tree and carry no source of their own."""
if artifact.get("_format") != "hh-sol-artifact-1":
return None
source_name = artifact.get("sourceName")
return source_name if isinstance(source_name, str) else None

def filter_artifacts(self, artifacts_dir: Path) -> List[Path]:
"""
Filter Hardhat artifacts - only contracts/, exclude .dbg.json and build-info/.
Expand Down
72 changes: 72 additions & 0 deletions certora_autosetup/build_systems/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
parsing and artifact filtering.
"""

import json
import os
import sys
from abc import ABC, abstractmethod
Expand Down Expand Up @@ -128,6 +129,77 @@ def holds_artifacts(artifacts_dir: Path) -> bool:
"""
pass

@staticmethod
@abstractmethod
def recorded_source(artifact: dict) -> Optional[str]:
"""
The source path *artifact* records having been compiled from, or None.

Every build system stamps this into its artifacts, under its own key and in its own
frame: some relative to the project that ran the build, some absolute. Callers get
the value as written and decide what to do with it; ``artifacts_belong_to`` is the
one that cares. None covers both a payload this build system did not write (a
sidecar or a build-info file caught by the same directory walk) and one that records
no source at all.

Args:
artifact: Parsed JSON of a single artifact file

Returns:
Source path as recorded, or None if this artifact records none
"""
pass

@classmethod
def artifacts_belong_to(cls, config_dir: Path, artifacts_dir: Path, limit: int = 20) -> bool:
"""
Whether the artifacts in *artifacts_dir* were written by the project at *config_dir*.

Two configs can name the same physical artifact directory — a root ``foundry.toml``
with ``out = 'pkg/out'`` next to ``pkg/foundry.toml`` with the default ``out`` — and
only one of them ran. The artifacts settle it: the source path each one records
resolves against the project that produced it, so if none of them lands inside
*config_dir*, these are somebody else's artifacts and this is the wrong frame to
read them in.

An absolute recorded path is tested for containment; a relative one is resolved
against *config_dir* and tested for existence. Sampling stops at the first artifact
that answers, so the common case reads one file.

Artifacts that record no source at all (older Foundry, metadata stripped) answer
True: absence of evidence leaves the caller where it was.

Args:
config_dir: Candidate project directory
artifacts_dir: Directory holding this build system's artifacts
limit: How many artifacts to read before giving up on finding a recorded source

Returns:
True if these artifacts are this project's, or record nothing to judge by
"""
read = 0
for json_file in artifacts_dir.rglob("*.json"):
if read >= limit:
break
try:
with json_file.open() as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
continue
if not isinstance(data, dict):
continue
source = cls.recorded_source(data)
if source is None:
continue
read += 1
candidate = Path(source)
if candidate.is_absolute():
if candidate.is_relative_to(config_dir):
return True
elif (config_dir / candidate).exists():
return True
return read == 0

@abstractmethod
def filter_artifacts(self, artifacts_dir: Path) -> List[Path]:
"""
Expand Down
7 changes: 7 additions & 0 deletions certora_autosetup/build_systems/truffle.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ def holds_artifacts(artifacts_dir: Path) -> bool:
"""Truffle writes one flat `<ContractName>.json` per contract into its build dir."""
return artifacts_dir.is_dir() and any(artifacts_dir.glob("*.json"))

@staticmethod
def recorded_source(artifact: dict) -> Optional[str]:
"""Truffle records `sourcePath`, and unlike the others it is the absolute path the
source had on the machine that compiled it."""
source_path = artifact.get("sourcePath")
return source_path if isinstance(source_path, str) else None

def filter_artifacts(self, artifacts_dir: Path) -> List[Path]:
"""Return Truffle's artifact JSONs — one flat `<ContractName>.json` per contract."""
return self._walk_and_filter_artifacts(
Expand Down
58 changes: 34 additions & 24 deletions certora_autosetup/utils/project_dir.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,20 @@
monorepos vendor a per-package ``foundry.toml`` under ``modules/`` or ``lib/`` while the
root config is what actually builds the tree, so the nearest config is often not the one
that ran. See ``find_build_config_dir``.

Reading the artifacts is the build systems' own business, so this module locates the
directory and asks the matching ``BuildSystemManager`` whether what is in it is theirs.
"""

import os
import tomllib
from pathlib import Path
from typing import Optional

# Build config filenames that mark a directory as a project root, in no particular order —
# presence of any one of them is enough to anchor there. Truffle has two: `truffle.js` is
# the v4 spelling, `truffle-config.js` everything since.
BUILD_CONFIG_FILENAMES = (
"foundry.toml",
"hardhat.config.js",
"hardhat.config.ts",
"truffle-config.js",
"truffle.js",
)
from certora_autosetup.build_systems.foundry import FoundryManager
from certora_autosetup.build_systems.hardhat import HardhatManager
from certora_autosetup.build_systems.manager import BuildSystemManager
from certora_autosetup.build_systems.truffle import TruffleManager

# Hardhat writes artifacts here unless the config sets `paths.artifacts`, and Truffle
# unless it sets `contracts_build_directory`. Reading either override means running node
Expand Down Expand Up @@ -65,31 +62,42 @@ def hardhat_artifact_dir(config_dir: Path) -> Optional[Path]:


def truffle_artifact_dir(config_dir: Path) -> Optional[Path]:
"""Where a Truffle project at *config_dir* writes artifacts, or None if it has no config."""
"""Where a Truffle project at *config_dir* writes artifacts, or None if it has no config.

Truffle answers to two config names: ``truffle.js`` is the v4 spelling, ``truffle-config.js``
everything since.
"""
if (config_dir / "truffle-config.js").exists() or (config_dir / "truffle.js").exists():
return config_dir / TRUFFLE_DEFAULT_BUILD_DIR
return None


def _artifact_dir_of(config_dir: Path) -> Optional[Path]:
"""Where *config_dir*'s build system would put artifacts, or None if it holds no config.
def _artifact_dir_of(config_dir: Path) -> Optional[tuple[type[BuildSystemManager], Path]]:
"""*config_dir*'s build system and where it would put artifacts, or None if it holds no
config.

A directory holding several configs answers for the first of them, in the same order
``BuildSystemDetector`` ranks them: any of the three is enough to recognise the
directory as a project that got built, which is all the caller asks.
directory as a project that got built, which is all the caller asks. The manager comes
back with the path because the manager is what knows how to read what it writes.
"""
return (
foundry_artifact_dir(config_dir)
or hardhat_artifact_dir(config_dir)
or truffle_artifact_dir(config_dir)
)
for manager, artifact_dir_of in (
(FoundryManager, foundry_artifact_dir),
(HardhatManager, hardhat_artifact_dir),
(TruffleManager, truffle_artifact_dir),
):
artifacts = artifact_dir_of(config_dir)
if artifacts is not None:
return manager, artifacts
return None


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 build system recognises
those artifacts as its own project's (``BuildSystemManager.artifacts_belong_to``). 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 @@ -120,9 +128,11 @@ 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():
return current
found = _artifact_dir_of(current)
if found is not None:
manager, artifacts = found
if artifacts.is_dir() and manager.artifacts_belong_to(current, artifacts):
return current
if current == root:
return root
current = current.parent
Expand Down
Loading
Loading