diff --git a/README.md b/README.md index 2756f95..6216008 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/vyupgrade/cli.py b/src/vyupgrade/cli.py index 39d0b94..4b7b1b3 100644 --- a/src/vyupgrade/cli.py +++ b/src/vyupgrade/cli.py @@ -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, @@ -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: @@ -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( @@ -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 @@ -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 = ( @@ -227,6 +241,7 @@ def _prepare_rewrites(files: list[Path], config: Config) -> list[RewriteWork]: file_report, source_compile, source_version, + source_compiler, ) ) return rewrites @@ -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 @@ -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( @@ -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) diff --git a/src/vyupgrade/models.py b/src/vyupgrade/models.py index cf10b37..71b88e7 100644 --- a/src/vyupgrade/models.py +++ b/src/vyupgrade/models.py @@ -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 @@ -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, diff --git a/src/vyupgrade/reporting.py b/src/vyupgrade/reporting.py index aaad029..e775b02 100644 --- a/src/vyupgrade/reporting.py +++ b/src/vyupgrade/reporting.py @@ -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), diff --git a/src/vyupgrade/versions.py b/src/vyupgrade/versions.py index eacdbcf..0c947b7 100644 --- a/src/vyupgrade/versions.py +++ b/src/vyupgrade/versions.py @@ -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): diff --git a/tests/test_cli.py b/tests/test_cli.py index 90ec7e3..aa323a0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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: @@ -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( diff --git a/tests/test_reporting.py b/tests/test_reporting.py index 6d426c1..36553f5 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -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"), diff --git a/tests/test_versions.py b/tests/test_versions.py index cf7b12e..fa9471a 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -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, @@ -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")