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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ Paths may be files or directories; directories are searched recursively for
`#pragma version` (or legacy `# @version`) line. Pass `--source-version` to
override the inference for files that have no pragma.

For broad source pragmas, rule gating uses the oldest satisfying compiler so
historical migrations still run, while source validation uses the newest
satisfying compiler no newer than the requested target.

### How it validates

For each file, `vyupgrade` compiles the original under its source compiler and
Expand Down
32 changes: 27 additions & 5 deletions src/vyupgrade/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from .rules import RULE_CHANGES, apply_rules
from .versions import (
MigrationContext,
compiler_version_for_source_validation,
compiler_version_for_spec,
default_evm_version_for_spec,
infer_pragma,
Expand All @@ -41,6 +42,7 @@ class RewriteWork:
report: FileReport
source_compile: CompileResult
source_version: str | None
source_compiler: str | None


def main(argv: list[str] | None = None) -> int:
Expand Down Expand Up @@ -184,6 +186,7 @@ def _prepare_rewrites(files: list[Path], config: Config) -> list[RewriteWork]:
changed=False,
fixes=rewrite.fixes,
diagnostics=rewrite.diagnostics,
source_version=source_version,
)
rewrites.append(
RewriteWork(
Expand All @@ -193,10 +196,16 @@ def _prepare_rewrites(files: list[Path], config: Config) -> list[RewriteWork]:
file_report,
CompileResult("skipped"),
source_version,
None,
)
)
continue
source_compile = compile_source_file(path, config, source_version)
inferred_source_compiler = compiler_version_for_source_validation(
source_version, config.target_version, original
)
source_compile_version = source_version if config.source_vyper else inferred_source_compiler
source_compiler = None if config.source_vyper else inferred_source_compiler
source_compile = compile_source_file(path, config, source_compile_version)
source_ast = source_compile.artifacts.get("ast") if source_compile.artifacts else None
file_config = replace(
config, source_ast=source_ast if isinstance(source_ast, dict) else None
Expand All @@ -213,7 +222,12 @@ def _prepare_rewrites(files: list[Path], config: Config) -> list[RewriteWork]:
rewrite.generated_files.extend(split.generated)
changed = original != rewrite.source
file_report = FileReport(
path=path, changed=changed, fixes=rewrite.fixes, diagnostics=rewrite.diagnostics
path=path,
changed=changed,
fixes=rewrite.fixes,
diagnostics=rewrite.diagnostics,
source_version=source_version,
source_compiler=source_compiler,
)
file_report.source_compile = source_compile.status
file_report.source_error = (
Expand All @@ -227,6 +241,7 @@ def _prepare_rewrites(files: list[Path], config: Config) -> list[RewriteWork]:
file_report,
source_compile,
source_version,
source_compiler,
)
)
return rewrites
Expand Down Expand Up @@ -266,7 +281,9 @@ def _verify_rewrites(rewrites: list[RewriteWork], config: Config) -> bool:
work.report.abi_diff = abi_diff
work.report.method_id_diff = method_id_diff
work.report.storage_layout_diff = storage_layout_diff
_add_validation_diagnostics(work.report, work.source_version, config)
_add_validation_diagnostics(
work.report, work.source_version, config, work.source_compiler
)
return any_target_failed


Expand Down Expand Up @@ -381,7 +398,10 @@ def _string_or_none(value: object) -> str | None:


def _add_validation_diagnostics(
file_report: FileReport, source_version: str | None, config: Config
file_report: FileReport,
source_version: str | None,
config: Config,
source_compiler: str | None = None,
) -> None:
if file_report.source_compile == "failed":
_add_diagnostic_if_enabled(
Expand Down Expand Up @@ -413,7 +433,9 @@ def _add_validation_diagnostics(
source_version,
config,
)
evm_diagnostic = _evm_default_diagnostic(source_version, config.target_version)
evm_diagnostic = _evm_default_diagnostic(
source_compiler or source_version, config.target_version
)
if evm_diagnostic is not None and _rule_enabled(evm_diagnostic.rule, source_version, config):
file_report.diagnostics.append(evm_diagnostic)

Expand Down
4 changes: 4 additions & 0 deletions src/vyupgrade/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ class FileReport:
changed: bool = False
fixes: list[Fix] = field(default_factory=list)
diagnostics: list[Diagnostic] = field(default_factory=list)
source_version: str | None = None
source_compiler: str | None = None
source_compile: str = "skipped"
target_compile: str = "skipped"
source_error: str | None = None
Expand Down Expand Up @@ -121,6 +123,8 @@ def to_json_obj(self) -> dict[str, Any]:
"fixes": [fix.__dict__ for fix in file.fixes],
"diagnostics": [diag.__dict__ for diag in file.diagnostics],
"validation": {
"source_version": file.source_version,
"source_compiler": file.source_compiler,
"source_compile": file.source_compile,
"target_compile": file.target_compile,
"abi_equal": file.abi_equal,
Expand Down
7 changes: 6 additions & 1 deletion src/vyupgrade/reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,12 +207,17 @@ def _render_file(console: Console, file: FileReport) -> None:


def _compile_outputs(file: FileReport) -> tuple[tuple[str, str, str, str | None], ...]:
source_label = _compile_label("source compile", file.source_compiler)
return (
("source compile", file.source_compile, "source error", file.source_error),
(source_label, file.source_compile, "source error", file.source_error),
("target compile", file.target_compile, "target error", file.target_error),
)


def _compile_label(label: str, version: str | None) -> str:
return f"{label} ({version})" if version else label


def _artifact_checks(file: FileReport) -> tuple[tuple[str, bool | None, list[str]], ...]:
return (
("ABI unchanged", file.abi_equal, file.abi_diff),
Expand Down
21 changes: 21 additions & 0 deletions src/vyupgrade/versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,27 @@ def compiler_version_for_source(spec: str | None, source: str) -> str | None:
return str(version)


def compiler_version_for_source_validation(
spec: str | None, target_spec: str, source: str
) -> str | None:
versions = known_versions_satisfying(spec)
if not versions:
return compiler_version_for_source(spec, source)

target = parse_version(compiler_version_for_spec(target_spec)) or parse_version(target_spec)
candidates = tuple(version for version in versions if target is None or version <= target)
if not candidates:
return compiler_version_for_source(spec, source)

hinted = _source_syntax_floor(source)
if hinted is not None:
hinted_candidates = tuple(version for version in candidates if version >= hinted)
if hinted_candidates:
candidates = hinted_candidates

return str(candidates[-1])


def _source_syntax_floor(source: str) -> VyperVersion | None:
floors: list[VyperVersion] = []
if re.search(r"\buint(?:8|16|32|64)\b", source):
Expand Down
109 changes: 109 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,104 @@ def __init__():
assert json.loads(second_report.read_text())["files"][0]["changed"] is False


def test_broad_pragma_source_validation_uses_target_bounded_compiler(
tmp_path: Path, monkeypatch
) -> None:
contract = tmp_path / "Contract.vy"
contract.write_text(
"""#pragma version >0.3.10

@external
def f() -> uint256:
return 1
""",
encoding="utf-8",
)
seen: dict[str, str | None] = {}

def compile_source_file(
path: Path, config: Config, source_version: str | None
) -> CompileResult:
seen["source_version"] = source_version
return CompileResult("passed", artifacts={})

def compile_target_source(
path: Path,
source: str,
config: Config,
overlay=None,
) -> CompileResult:
return CompileResult("passed", artifacts={})

monkeypatch.setattr(cli, "compile_source_file", compile_source_file)
monkeypatch.setattr(cli, "compile_target_source", compile_target_source)

report = tmp_path / "report.json"
code = main([str(contract), "--check", "--report-json", str(report)])

assert code == 1
assert seen["source_version"] == "0.4.3"
data = json.loads(report.read_text())
file_report = data["files"][0]
assert [fix["rule"] for fix in file_report["fixes"]] == ["VY001"]
assert file_report["validation"]["source_version"] == ">0.3.10"
assert file_report["validation"]["source_compiler"] == "0.4.3"
assert not [diag for diag in file_report["diagnostics"] if diag["rule"] == "VYD009"]


def test_explicit_source_vyper_does_not_report_inferred_compiler(
tmp_path: Path, monkeypatch
) -> None:
contract = tmp_path / "Contract.vy"
contract.write_text(
"""#pragma version >0.3.10

@external
def f() -> uint256:
return 1
""",
encoding="utf-8",
)
seen: dict[str, str | None] = {}

def compile_source_file(
path: Path, config: Config, source_version: str | None
) -> CompileResult:
seen["source_version"] = source_version
seen["source_vyper"] = config.source_vyper
return CompileResult("passed", artifacts={})

def compile_target_source(
path: Path,
source: str,
config: Config,
overlay=None,
) -> CompileResult:
return CompileResult("passed", artifacts={})

monkeypatch.setattr(cli, "compile_source_file", compile_source_file)
monkeypatch.setattr(cli, "compile_target_source", compile_target_source)

report = tmp_path / "report.json"
code = main(
[
str(contract),
"--check",
"--source-vyper",
"/tmp/vyper",
"--report-json",
str(report),
]
)

assert code == 1
assert seen["source_vyper"] == "/tmp/vyper"
assert seen["source_version"] == ">0.3.10"
validation = json.loads(report.read_text())["files"][0]["validation"]
assert validation["source_version"] == ">0.3.10"
assert validation["source_compiler"] is None


def test_write_mode_reports_missing_mamushi_formatter(
tmp_path: Path, monkeypatch, capsys, passing_compiler
) -> None:
Expand Down Expand Up @@ -393,6 +491,17 @@ def test_validation_diagnostics_respect_rule_ignore(tmp_path: Path) -> None:
assert not report.diagnostics


def test_validation_diagnostics_use_resolved_source_compiler_for_evm_default(
tmp_path: Path,
) -> None:
report = FileReport(path=tmp_path / "Contract.vy")
config = Config(paths=(report.path,), target_version="0.4.3")

_add_validation_diagnostics(report, ">0.3.10", config, source_compiler="0.4.3")

assert not [diagnostic for diagnostic in report.diagnostics if diagnostic.rule == "VYD009"]


def test_source_newer_than_target_skips_compile_and_reports_error(tmp_path: Path) -> None:
contract = tmp_path / "newer.vy"
contract.write_text(
Expand Down
15 changes: 15 additions & 0 deletions tests/test_reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,21 @@ def test_render_text_hides_stderr_for_successful_compiles() -> None:
assert "warning output" not in text


def test_render_text_labels_resolved_source_compiler() -> None:
file_report = FileReport(
path=Path("ok.vy"),
changed=True,
source_compiler="0.4.3",
source_compile="passed",
target_compile="passed",
)
report = RunReport(source_version=None, target_version="0.4.3", files=[file_report])

text = render_text(report)

assert "source compile (0.4.3): passed" in text


def test_render_text_groups_repeated_fixes_and_diagnostics_by_rule_message() -> None:
file_report = FileReport(
path=Path("grouped.vy"),
Expand Down
8 changes: 8 additions & 0 deletions tests/test_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
MigrationContext,
VyperVersion,
compiler_version_for_source,
compiler_version_for_source_validation,
compiler_version_for_spec,
default_evm_version_for_spec,
is_supported_source_version,
Expand Down Expand Up @@ -62,6 +63,13 @@ def test_source_syntax_hints_raise_broad_pragma_compiler_floor() -> None:
assert compiler_version_for_source(">=0.3.0,<0.3.4", "enum Side:\n BUY\n") == "0.3.0"


def test_source_validation_compiler_uses_newest_target_bounded_version() -> None:
assert minimum_satisfying_version(">0.3.10") == VyperVersion("0.4.0")
assert compiler_version_for_source_validation(">0.3.10", "0.4.3", "") == "0.4.3"
assert compiler_version_for_source_validation(">=0.4.0", "0.4.3", "") == "0.4.3"
assert compiler_version_for_source_validation(">=0.4.0", "0.5.0a2", "") == "0.5.0a2"


def test_migration_context_tracks_patch_level_crossings() -> None:
older = MigrationContext.from_specs("0.4.1", "0.4.3")
current = MigrationContext.from_specs("0.4.2", "0.4.3")
Expand Down