diff --git a/scripts/compare_pr_yul_test_expectations.py b/scripts/compare_pr_yul_test_expectations.py new file mode 100644 index 000000000000..8f4a5c976fb0 --- /dev/null +++ b/scripts/compare_pr_yul_test_expectations.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +""" +Compare Yul test expected outputs between base and PR branch using yuldiff. +Handles: + - yulOptimizerTests/*.yul (expected output after "// step:" header) + - cmdlineTests/*/output (plain text with Yul objects) + - cmdlineTests/*/output.json (Yul embedded in JSON string values) +""" + +import argparse +import enum +import json +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + + +class FileType(enum.Enum): + YUL_OPTIMIZER_TEST = enum.auto() + CMDLINE_OUTPUT_JSON = enum.auto() + CMDLINE_OUTPUT_TEXT = enum.auto() + + +class CompareStatus(enum.Enum): + EQUIVALENT = enum.auto() + MISMATCH = enum.auto() + YULDIFF_ERROR = enum.auto() + TIMEOUT = enum.auto() + ERROR = enum.auto() + + +@dataclass +class CompareResult: + status: CompareStatus + message: str = "" + + +def git_show(ref, path): + return subprocess.check_output( + ["git", "show", f"{ref}:{path}"], text=True + ) + + +def sanitize_yul(source): + """Replace unparsable test placeholders.""" + return source.replace('hex""', 'hex""') + + +def run_yuldiff(yuldiff_binary, yul_a: str, yul_b: str) -> CompareResult: + """Run yuldiff on two Yul source strings.""" + yul_a = sanitize_yul(yul_a) + yul_b = sanitize_yul(yul_b) + with ( + tempfile.NamedTemporaryFile(mode="w", suffix=".yul") as fa, + tempfile.NamedTemporaryFile(mode="w", suffix=".yul") as fb, + ): + fa.write(yul_a) + fb.write(yul_b) + fa.flush() + fb.flush() + try: + result = subprocess.run( + [yuldiff_binary, fa.name, fb.name], + capture_output=True, text=True, timeout=10, check=False + ) + if result.returncode == 0: + return CompareResult(CompareStatus.EQUIVALENT) + msg = result.stdout.strip() + if len(msg) == 0 and len(result.stderr.strip()) > 0: + return CompareResult(CompareStatus.YULDIFF_ERROR, result.stderr.strip()) + return CompareResult(CompareStatus.MISMATCH, msg if len(msg) > 0 else "unknown error") + except subprocess.TimeoutExpired: + return CompareResult(CompareStatus.TIMEOUT) + except OSError as e: + return CompareResult(CompareStatus.ERROR, str(e)) + + +def extract_optimizer_expected(content): + """Extract expected output from yulOptimizerTests .yul files.""" + separator = "// ----" + if separator not in content: + return [] + after_separator = content.split(separator, 1)[1] + + comment_lines = [] + for line in after_separator.split("\n")[1:]: + if line.startswith("// "): + comment_lines.append(line[3:]) + elif line.strip() == "//": + comment_lines.append("") + else: + break + # First line is "step: ", followed by an empty line, then the Yul code. + if len(comment_lines) < 3 or not comment_lines[0].startswith("step:"): + return [] + yul = "\n".join(comment_lines[2:]).strip() + if len(yul) == 0: + return [] + return [f'object "test" {{ code {{\n{yul}\n}} }}'] + + +# All section headers the CLI can emit (from CommandLineInterface.cpp). +_KNOWN_SECTION_HEADERS = { + "IR:", "IR AST:", "Optimized IR:", "Optimized IR AST:", + "Yul Control Flow Graph:", + "EVM assembly:", "Binary:", "Binary of the runtime part:", + "Opcodes:", "Binary representation:", "Text representation:", + "AST:", "JSON AST (compact format):", + "Metadata:", "Contract JSON ABI", + "Contract Storage Layout:", "Contract Transient Storage Layout:", + "Gas estimation:", "Function signatures:", + "Error signatures:", "Event signatures:", + "Pretty printed source:", + "Debug Data (ethdebug/format/program):", + "Debug Data of the runtime part (ethdebug/format/program):", +} + +_YUL_SECTION_HEADERS = {"IR:", "Optimized IR:", "Pretty printed source:"} + + +def _is_section_header(line: str) -> bool: + """Check if a line is a cmdline output section header.""" + stripped = line.strip() + if stripped.startswith("=======") and stripped.endswith("======="): + return True + return stripped in _KNOWN_SECTION_HEADERS + + +def extract_yul_objects_from_cmdline_output_text(content: str) -> list[str]: + """Extract Yul objects from cmdline output text files. + + Parses section headers and collects content under IR/Optimized IR/Pretty printed source. + """ + objects = [] + current_lines: list[str] | None = None + + for line in content.split("\n"): + if _is_section_header(line): + if current_lines is not None and len(current_lines) > 0: + objects.append("\n".join(current_lines)) + + if line.strip() in _YUL_SECTION_HEADERS: + current_lines = [] + else: + current_lines = None + elif current_lines is not None: + current_lines.append(line) + + if current_lines is not None and len(current_lines) > 0: + objects.append("\n".join(current_lines)) + + return objects + + +_YUL_JSON_KEYS = ("ir", "irOptimized") + + +def extract_yul_from_output_json(content): + """Extract Yul object strings from Standard JSON output files. + + Yul IR lives at contracts...ir and .irOptimized. + """ + data = json.loads(content, strict=False) + yul_strings = [] + contracts = data.get("contracts", {}) + for source_units in contracts.values(): + for contract in source_units.values(): + for key in _YUL_JSON_KEYS: + yul = contract.get(key, "") + if isinstance(yul, str) and len(yul.strip()) > 0: + yul_strings.append(yul.strip()) + return yul_strings + + +def get_changed_files(base_ref, pr_ref): + """Return (modified, added, deleted) file lists between two refs.""" + def diff_filter(filt): + output = subprocess.check_output( + ["git", "diff", "--name-only", f"--diff-filter={filt}", base_ref, pr_ref], text=True + ).strip() + if len(output) == 0: + return [] + return output.split("\n") + return diff_filter("M"), diff_filter("A"), diff_filter("D") + + +_YUL_OPTIMIZER_TEST_DIRS = { + "yulOptimizerTests", + "yulControlFlowGraph", + "yulStackLayout" +} + + +def classify_file(path_str: str) -> FileType | None: + path = Path(path_str) + if path.suffix == ".yul" and any(d in path.parts for d in _YUL_OPTIMIZER_TEST_DIRS): + return FileType.YUL_OPTIMIZER_TEST + if "cmdlineTests" in path.parts: + if path.name == "output.json": + return FileType.CMDLINE_OUTPUT_JSON + if path.name in ("output", "err"): + return FileType.CMDLINE_OUTPUT_TEXT + return None + + +def extract_yul(content: str, file_type: FileType) -> list[str]: + match file_type: + case FileType.YUL_OPTIMIZER_TEST: + return extract_optimizer_expected(content) + case FileType.CMDLINE_OUTPUT_JSON: + return extract_yul_from_output_json(content) + case FileType.CMDLINE_OUTPUT_TEXT: + return extract_yul_objects_from_cmdline_output_text(content) + case _: + raise ValueError(f"Unhandled file type: {file_type}") + + +def fetch_pr_ref(pr_id, remote): + """Fetch a PR and return a local ref for it.""" + ref = f"{remote}/pr-{pr_id}" + subprocess.check_call(["git", "fetch", remote, f"pull/{pr_id}/head:{ref}"]) + return ref + + +def find_base_ref(pr_ref, base_branch): + return subprocess.check_output( + ["git", "merge-base", base_branch, pr_ref], text=True + ).strip() + + +def main(): + parser = argparse.ArgumentParser( + description="Compare Yul test expected outputs between base and PR using yuldiff." + ) + parser.add_argument("yuldiff", help="Path to the yuldiff binary") + parser.add_argument("pr", type=int, nargs="?", default=None, help="PR number to compare (omit to compare HEAD against base)") + parser.add_argument( + "--base", "-b", + default="origin/develop", + help="Base branch for merge-base calculation (default: origin/develop)", + ) + parser.add_argument( + "--remote", "-r", + default="origin", + help="Git remote to fetch PRs from (default: origin)", + ) + args = parser.parse_args() + + yuldiff_binary = Path(args.yuldiff) + if not yuldiff_binary.exists(): + parser.error(f"yuldiff binary not found: {yuldiff_binary}") + + if args.pr is not None: + pr_ref = fetch_pr_ref(args.pr, args.remote) + label = f"PR #{args.pr}: {pr_ref}" + else: + pr_ref = "HEAD" + label = f"HEAD ({subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], text=True).strip()})" + base_ref = find_base_ref(pr_ref, args.base) + + print(f"{label} (base: {base_ref})") + + modified, added, deleted = get_changed_files(base_ref, pr_ref) + all_files = modified + added + deleted + + equivalent = 0 + mismatches = [] + errors = [] + skipped = [] + + test_files = [] + classified = {} + for f in all_files: + ftype = classify_file(f) + if ftype: + test_files.append((f, ftype)) + classified[f] = ftype + + added_set = set(added) + deleted_set = set(deleted) + + print("Changed files:") + for f in all_files: + if f in classified: + print(f" \033[92m{f}\033[0m [{classified[f].name}]") + else: + print(f" {f}") + + print(f"\n{len(test_files)} of {len(all_files)} files have comparable Yul content\n") + + for filepath, ftype in sorted(test_files): + if filepath in added_set or filepath in deleted_set: + reason = "added" if filepath in added_set else "deleted" + skipped.append((filepath, reason)) + continue + + base_content = git_show(base_ref, filepath) + pr_content = git_show(pr_ref, filepath) + + if base_content == pr_content: + equivalent += 1 + continue + + base_yuls = extract_yul(base_content, ftype) + pr_yuls = extract_yul(pr_content, ftype) + + if len(base_yuls) == 0 and len(pr_yuls) == 0: + skipped.append((filepath, "no Yul objects extracted")) + continue + + if len(base_yuls) != len(pr_yuls): + mismatches.append((filepath, f"different number of Yul objects: {len(base_yuls)} vs {len(pr_yuls)}")) + continue + + file_ok = True + for idx, (yul_a, yul_b) in enumerate(zip(base_yuls, pr_yuls)): + if yul_a == yul_b: + continue + + cmp = run_yuldiff(yuldiff_binary, yul_a, yul_b) + match cmp.status: + case CompareStatus.EQUIVALENT: + continue + case CompareStatus.MISMATCH: + mismatches.append((filepath, cmp.message)) + case CompareStatus.YULDIFF_ERROR | CompareStatus.TIMEOUT | CompareStatus.ERROR: + errors.append((filepath, idx, cmp.message)) + case _: + raise ValueError(f"Unhandled compare status: {cmp.status}") + file_ok = False + break + + if file_ok: + equivalent += 1 + + print("=" * 50) + print(f"RESULTS: {len(test_files)} test files") + print(f" Equivalent: {equivalent}") + print(f" Mismatched: {len(mismatches)}") + print(f" Errors: {len(errors)}") + print(f" Skipped: {len(skipped)}") + print("=" * 50) + + if len(mismatches) > 0: + print("\nMismatched files:") + for f, msg in mismatches: + print(f" - {f}") + for line in msg.split("\n"): + print(f" {line}") + + if len(errors) > 0: + print("\nErrors:") + for f, idx, msg in errors: + print(f" - {f} (object {idx}): {msg}") + + if len(skipped) > 0: + print("\nSkipped files:") + for f, reason in skipped: + print(f" - {f} ({reason})") + + if len(mismatches) > 0 or len(errors) > 0: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index c6c8040f8536..898343b16122 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -209,6 +209,11 @@ set(yul_phaser_sources ) detect_stray_source_files("${yul_phaser_sources}" "yulPhaser/") +set(yul_ast_comparator_sources + yuldiff/ASTComparator.cpp +) +detect_stray_source_files("${yul_ast_comparator_sources}" "yuldiff/") + add_executable(soltest ${sources} ${contracts_sources} ${libsolutil_sources} @@ -219,8 +224,9 @@ add_executable(soltest ${sources} ${libsolidity_util_sources} ${solcli_sources} ${yul_phaser_sources} + ${yul_ast_comparator_sources} ) -target_link_libraries(soltest PRIVATE solcli libsolc yul solidity smtutil solutil phaser Boost::boost yulInterpreter evmasm Boost::filesystem Boost::program_options Boost::unit_test_framework evmc) +target_link_libraries(soltest PRIVATE solcli libsolc yul solidity smtutil solutil phaser libyuldiff Boost::boost yulInterpreter evmasm Boost::filesystem Boost::program_options Boost::unit_test_framework evmc) # Special compilation flag for Visual Studio (version 2019 at least affected) diff --git a/test/yuldiff/ASTComparator.cpp b/test/yuldiff/ASTComparator.cpp new file mode 100644 index 000000000000..b076537f5d41 --- /dev/null +++ b/test/yuldiff/ASTComparator.cpp @@ -0,0 +1,353 @@ +/* + This file is part of solidity. + + solidity is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + solidity is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with solidity. If not, see . +*/ +// SPDX-License-Identifier: GPL-3.0 + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +using namespace solidity; +using namespace solidity::yul; +using namespace solidity::langutil; +using namespace solidity::tools::cmpast; + +namespace +{ + +Dialect const& dialect() +{ + static auto const& d = EVMDialect::strictAssemblyForEVMObjects(EVMVersion::current(), std::nullopt); + return d; +} + +std::shared_ptr parse(std::string const& _source) +{ + ErrorList errors; + ErrorReporter errorReporter(errors); + CharStream stream(_source, "test"); + std::shared_ptr const scanner = std::make_shared(stream); + auto object = ObjectParser(errorReporter, dialect()).parse(scanner, false); + BOOST_REQUIRE_MESSAGE(object && !errorReporter.hasErrors(), "Failed to parse: " + _source); + AsmAnalyzer::analyzeStrictAssertCorrect(*object); + return object; +} + +bool equivalent(std::string const& _a, std::string const& _b) +{ + std::shared_ptr const objA = parse(_a); + std::shared_ptr const objB = parse(_b); + ASTComparator cmp(dialect()); + return static_cast(cmp.compareObjects(*objA, *objB)); +} + +std::string mismatchReason(std::string const& _a, std::string const& _b) +{ + std::shared_ptr const objA = parse(_a); + std::shared_ptr const objB = parse(_b); + ASTComparator cmp(dialect()); + auto const result = cmp.compareObjects(*objA, *objB); + return result.mismatch().reason; +} + +} + +BOOST_AUTO_TEST_SUITE(YulASTComparator, *boost::unit_test::label("nooptions")) +BOOST_AUTO_TEST_SUITE(ASTComparatorTest) + +BOOST_AUTO_TEST_SUITE(equivalence) +BOOST_AUTO_TEST_CASE(empty_objects_are_equivalent) +{ + BOOST_TEST(equivalent( + "object \"A\" { code { } }", + "object \"B\" { code { } }" + )); +} + +BOOST_AUTO_TEST_CASE(identical_code_is_equivalent) +{ + std::string const src = "object \"X\" { code { let x := 1 let y := add(x, 2) } }"; + BOOST_TEST(equivalent(src, src)); +} + +BOOST_AUTO_TEST_CASE(renamed_variables_are_equivalent) +{ + BOOST_TEST(equivalent( + "object \"A\" { code { let x := 1 let y := add(x, 2) } }", + "object \"A\" { code { let a := 1 let b := add(a, 2) } }" + )); +} + +BOOST_AUTO_TEST_CASE(renamed_function_names_are_equivalent) +{ + BOOST_TEST(equivalent( + "object \"A\" { code { function foo() -> r { r := 1 } let x := foo() } }", + "object \"A\" { code { function bar() -> s { s := 1 } let y := bar() } }" + )); +} + +BOOST_AUTO_TEST_CASE(renamed_parameters_are_equivalent) +{ + BOOST_TEST(equivalent( + "object \"A\" { code { function f(a, b) -> r { r := add(a, b) } } }", + "object \"A\" { code { function g(x, y) -> z { z := add(x, y) } } }" + )); +} + +BOOST_AUTO_TEST_CASE(renamed_for_loop_variables_are_equivalent) +{ + BOOST_TEST(equivalent( + "object \"A\" { code { for { let i := 0 } lt(i, 10) { i := add(i, 1) } { } } }", + "object \"A\" { code { for { let j := 0 } lt(j, 10) { j := add(j, 1) } { } } }" + )); +} +BOOST_AUTO_TEST_SUITE_END() + +BOOST_AUTO_TEST_SUITE(inconsistent_renaming) +BOOST_AUTO_TEST_CASE(inconsistent_variable_renaming_not_equivalent) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { let x := 1 let y := 2 let z := add(x, y) } }", + "object \"A\" { code { let a := 1 let b := 2 let c := add(b, a) } }" + )); +} + +BOOST_AUTO_TEST_CASE(inconsistent_function_renaming_not_equivalent) +{ + // Two different functions map to the same name + BOOST_TEST(!equivalent( + "object \"A\" { code { function f() { } function g() { } f() g() } }", + "object \"A\" { code { function h() { } function k() { } h() h() } }" + )); +} +BOOST_AUTO_TEST_SUITE_END() + +BOOST_AUTO_TEST_SUITE(mismatch) +BOOST_AUTO_TEST_CASE(different_statement_types_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { let x := 1 } }", + "object \"A\" { code { pop(1) } }" + )); +} + +BOOST_AUTO_TEST_CASE(different_statement_count_in_block_mismatch) +{ + auto reason = mismatchReason( + "object \"A\" { code { let x := 1 } }", + "object \"A\" { code { let x := 1 let y := 2 } }" + ); + BOOST_TEST(reason.find("block statement count differs") != std::string::npos); +} + +BOOST_AUTO_TEST_CASE(assignment_value_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { let x := 0 x := 42 } }", + "object \"A\" { code { let x := 0 x := 99 } }" + )); +} + +BOOST_AUTO_TEST_CASE(function_parameter_count_mismatch) +{ + auto reason = mismatchReason( + "object \"A\" { code { function f(a) { } } }", + "object \"A\" { code { function f(a, b) { } } }" + ); + BOOST_TEST(reason.find("parameter count differs") != std::string::npos); +} + +BOOST_AUTO_TEST_CASE(function_return_variable_count_mismatch) +{ + auto reason = mismatchReason( + "object \"A\" { code { function f() -> a { a := 1 } } }", + "object \"A\" { code { function f() -> a, b { a := 1 b := 2 } } }" + ); + BOOST_TEST(reason.find("return variable count differs") != std::string::npos); +} + +BOOST_AUTO_TEST_CASE(function_body_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { function f() -> r { r := 1 } } }", + "object \"A\" { code { function f() -> r { r := 2 } } }" + )); +} + +BOOST_AUTO_TEST_CASE(if_condition_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { if 1 { } } }", + "object \"A\" { code { if 0 { } } }" + )); +} + +BOOST_AUTO_TEST_CASE(if_body_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { if 1 { pop(1) } } }", + "object \"A\" { code { if 1 { pop(2) } } }" + )); +} + +BOOST_AUTO_TEST_CASE(switch_case_count_mismatch) +{ + auto reason = mismatchReason( + "object \"A\" { code { switch 1 case 0 { } default { } } }", + "object \"A\" { code { switch 1 case 0 { } case 1 { } default { } } }" + ); + BOOST_TEST(reason.find("case count differs") != std::string::npos); +} + +BOOST_AUTO_TEST_CASE(switch_case_value_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { switch 1 case 0 { } default { } } }", + "object \"A\" { code { switch 1 case 1 { } default { } } }" + )); +} + +BOOST_AUTO_TEST_CASE(switch_case_body_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { switch 1 case 0 { pop(1) } default { } } }", + "object \"A\" { code { switch 1 case 0 { pop(2) } default { } } }" + )); +} + +BOOST_AUTO_TEST_CASE(switch_default_vs_nondefault_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { switch 1 case 0 { } default { } } }", + "object \"A\" { code { switch 1 case 0 { } case 1 { } } }" + )); +} + +BOOST_AUTO_TEST_CASE(switch_expression_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { switch 1 case 0 { } default { } } }", + "object \"A\" { code { switch 2 case 0 { } default { } } }" + )); +} + +BOOST_AUTO_TEST_CASE(for_loop_condition_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { for { } lt(0, 10) { } { } } }", + "object \"A\" { code { for { } lt(0, 20) { } { } } }" + )); +} + +BOOST_AUTO_TEST_CASE(for_loop_pre_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { for { let i := 0 } lt(i, 10) { i := add(i, 1) } { } } }", + "object \"A\" { code { for { let i := 1 } lt(i, 10) { i := add(i, 1) } { } } }" + )); +} + +BOOST_AUTO_TEST_CASE(for_loop_post_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { for { let i := 0 } lt(i, 10) { i := add(i, 1) } { } } }", + "object \"A\" { code { for { let i := 0 } lt(i, 10) { i := add(i, 2) } { } } }" + )); +} + +BOOST_AUTO_TEST_CASE(for_loop_body_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { for { } 1 { } { pop(1) } } }", + "object \"A\" { code { for { } 1 { } { pop(2) } } }" + )); +} + +BOOST_AUTO_TEST_CASE(literal_value_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { let x := 42 } }", + "object \"A\" { code { let x := 43 } }" + )); +} + +BOOST_AUTO_TEST_CASE(function_call_argument_count_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { function f(a) { } function g(a, b) { } f(1) } }", + "object \"A\" { code { function f(a) { } function g(a, b) { } g(1, 2) } }" + )); +} + +BOOST_AUTO_TEST_CASE(function_call_argument_value_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { pop(1) } }", + "object \"A\" { code { pop(2) } }" + )); +} + +BOOST_AUTO_TEST_CASE(builtin_function_mismatch) +{ + BOOST_TEST(!equivalent( + "object \"A\" { code { pop(add(1, 2)) } }", + "object \"A\" { code { pop(sub(1, 2)) } }" + )); +} + +BOOST_AUTO_TEST_CASE(sub_object_count_mismatch) +{ + auto reason = mismatchReason( + "object \"A\" { code { } object \"B\" { code { } } }", + "object \"A\" { code { } }" + ); + BOOST_TEST(reason.find("different number of sub-objects") != std::string::npos); +} + +BOOST_AUTO_TEST_SUITE_END() + +BOOST_AUTO_TEST_SUITE(scopes) +BOOST_AUTO_TEST_CASE(nested_block_scopes_allow_name_reuse) +{ + BOOST_TEST(equivalent( + "object \"A\" { code { { let x := 1 } { let x := 2 x := add(x, 5) } } }", + "object \"A\" { code { { let a := 1 } { let b := 2 b := add(b, 5) } } }" + )); +} + +BOOST_AUTO_TEST_CASE(function_scoping_independent_of_outer) +{ + BOOST_TEST(equivalent( + "object \"A\" { code { let x := 0 function f() -> r { let w := 1 r := w } } }", + "object \"A\" { code { let a := 0 function g() -> s { let b := 1 s := b } } }" + )); +} +BOOST_AUTO_TEST_SUITE_END() + +BOOST_AUTO_TEST_SUITE_END() +BOOST_AUTO_TEST_SUITE_END() diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index c67a509e1b73..5ec515407e49 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -35,3 +35,5 @@ add_executable(yul-phaser yulPhaser/main.cpp) target_link_libraries(yul-phaser PRIVATE phaser) install(TARGETS yul-phaser DESTINATION "${CMAKE_INSTALL_BINDIR}") + +add_subdirectory(yuldiff) diff --git a/tools/yuldiff/ASTComparator.cpp b/tools/yuldiff/ASTComparator.cpp new file mode 100644 index 000000000000..92f740d2057f --- /dev/null +++ b/tools/yuldiff/ASTComparator.cpp @@ -0,0 +1,262 @@ +/* + This file is part of solidity. + + solidity is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + solidity is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with solidity. If not, see . +*/ +// SPDX-License-Identifier: GPL-3.0 + +#include + +#include + +#include + +using namespace solidity::tools::cmpast; + +ASTComparator::ASTComparator(yul::Dialect const& _dialect): m_dialect(_dialect) {} + +ASTComparator::ComparisonResult ASTComparator::compareObjects(yul::Object const& _a, yul::Object const& _b) +{ + m_mismatch.reset(); + if (compare(_a, _b)) + return ComparisonResult::equivalent(); + return ComparisonResult::failure(std::move(*m_mismatch)); +} + +bool ASTComparator::compare(yul::Object const& _a, yul::Object const& _b) +{ + Path objectPath(*this, fmt::format(R"(Object("{}"/"{}"))", _a.name, _b.name)); + + if (_a.subObjects.size() != _b.subObjects.size()) + return fail(fmt::format("different number of sub-objects ({} vs {})", _a.subObjects.size(), _b.subObjects.size())); + + if (_a.hasCode() != _b.hasCode()) + return fail("one has code, the other does not"); + + if (_a.hasCode()) + { + ScopeBimap::Scope scope(m_bimap); + Path codePath(*this, "code"); + if (!compare(_a.code()->root(), _b.code()->root())) + return false; + } + + for (size_t i = 0; i < _a.subObjects.size(); ++i) + { + auto const& subA = *_a.subObjects[i]; + auto const& subB = *_b.subObjects[i]; + + if (typeid(subA) != typeid(subB)) + return fail(fmt::format("sub-object[{}]: type mismatch", i)); + + if (auto const* objA = dynamic_cast(&subA)) + { + if (!compare(*objA, dynamic_cast(subB))) + return false; + } + } + return true; +} + +ASTComparator::Path::Path(ASTComparator& _comparator, std::string _segment): m_comparator(_comparator) +{ + m_comparator.m_pathStack.push_back(std::move(_segment)); +} + +ASTComparator::Path::~Path() +{ + m_comparator.m_pathStack.pop_back(); +} + +std::string ASTComparator::currentPath() const +{ + return fmt::format("{}", fmt::join(m_pathStack, " > ")); +} + +bool ASTComparator::fail(std::string _reason) +{ + m_mismatch = Mismatch{currentPath(), std::move(_reason), {}, {}}; + return false; +} + +bool ASTComparator::compare(yul::Block const& _a, yul::Block const& _b) +{ + ScopeBimap::Scope blockScope(m_bimap); + if (_a.statements.size() != _b.statements.size()) + return fail(fmt::format("block statement count differs ({} vs {})", _a.statements.size(), _b.statements.size())); + for (size_t i = 0; i < _a.statements.size(); ++i) + { + Path statementPath(*this, fmt::format("stmt[{}]", i)); + if (!compare(_a.statements[i], _b.statements[i])) + return false; + } + return true; +} + +bool ASTComparator::compare(yul::ExpressionStatement const& _a, yul::ExpressionStatement const& _b) +{ + return compare(_a.expression, _b.expression); +} + +bool ASTComparator::compare(yul::Assignment const& _a, yul::Assignment const& _b) +{ + Path statementPath(*this, "Assignment"); + if (_a.variableNames.size() != _b.variableNames.size()) + return fail("assignment target count differs", _a, _b); + for (size_t i = 0; i < _a.variableNames.size(); ++i) + if (!compare(_a.variableNames[i], _b.variableNames[i])) + { + return fail(fmt::format("variable name {} differs", i), _a, _b); + } + if (static_cast(_a.value) != static_cast(_b.value)) + return fail("assignment value presence differs", _a, _b); + if (_a.value && !compare(*_a.value, *_b.value)) + return false; + return true; +} + +bool ASTComparator::compare(yul::VariableDeclaration const& _a, yul::VariableDeclaration const& _b) +{ + Path path(*this, "VariableDeclaration"); + if (_a.variables.size() != _b.variables.size()) + return fail("variable declaration count differs", _a, _b); + for (size_t i = 0; i < _a.variables.size(); ++i) + if (!m_bimap.tryMap(_a.variables[i].name, _b.variables[i].name)) + return fail(fmt::format(R"(variable name mapping inconsistent: "{}" vs "{}")", + _a.variables[i].name.str(), _b.variables[i].name.str()), _a, _b); + if (static_cast(_a.value) != static_cast(_b.value)) + return fail("variable declaration value presence differs", _a, _b); + if (_a.value && !compare(*_a.value, *_b.value)) + return false; + return true; +} + +bool ASTComparator::compare(yul::FunctionDefinition const& _a, yul::FunctionDefinition const& _b) +{ + Path path(*this, fmt::format(R"(FunctionDefinition("{}"/"{}"))", _a.name.str(), _b.name.str())); + if (!m_bimap.tryMap(_a.name, _b.name)) + return fail("function name mapping inconsistent"); + if (_a.parameters.size() != _b.parameters.size()) + return fail(fmt::format("parameter count differs ({} vs {})", _a.parameters.size(), _b.parameters.size())); + if (_a.returnVariables.size() != _b.returnVariables.size()) + return fail(fmt::format("return variable count differs ({} vs {})", _a.returnVariables.size(), _b.returnVariables.size())); + { + ScopeBimap::Scope scope(m_bimap); + for (size_t i = 0; i < _a.parameters.size(); ++i) + if (!m_bimap.tryMap(_a.parameters[i].name, _b.parameters[i].name)) + return fail("parameter name mapping inconsistent"); + for (size_t i = 0; i < _a.returnVariables.size(); ++i) + if (!m_bimap.tryMap(_a.returnVariables[i].name, _b.returnVariables[i].name)) + return fail("return variable name mapping inconsistent"); + if (!compare(_a.body, _b.body)) + return false; + } + return true; +} + +bool ASTComparator::compare(yul::If const& _a, yul::If const& _b) +{ + Path path(*this, "If"); + if (!compare(*_a.condition, *_b.condition)) + return false; + if (!compare(_a.body, _b.body)) + return false; + return true; +} + +bool ASTComparator::compare(yul::Switch const& _a, yul::Switch const& _b) +{ + Path path(*this, "Switch"); + if (!compare(*_a.expression, *_b.expression)) + return false; + if (_a.cases.size() != _b.cases.size()) + return fail(fmt::format("case count differs ({} vs {})", _a.cases.size(), _b.cases.size())); + for (size_t i = 0; i < _a.cases.size(); ++i) + { + Path casePath(*this, fmt::format("case[{}]", i)); + if (static_cast(_a.cases[i].value) != static_cast(_b.cases[i].value)) + return fail("case value presence differs (default vs non-default)"); + if (_a.cases[i].value) + if (!compare(*_a.cases[i].value, *_b.cases[i].value)) + return false; + if (!compare(_a.cases[i].body, _b.cases[i].body)) + return false; + } + return true; +} + +bool ASTComparator::compare(yul::ForLoop const& _a, yul::ForLoop const& _b) +{ + Path path(*this, "ForLoop"); + ScopeBimap::Scope scope(m_bimap); + return + compare(_a.pre, _b.pre) && + compare(*_a.condition, *_b.condition) && + compare(_a.post, _b.post) && + compare(_a.body, _b.body); +} + +bool ASTComparator::compare(yul::Break const&, yul::Break const&) +{ + return true; +} + +bool ASTComparator::compare(yul::Continue const&, yul::Continue const&) +{ + return true; +} + +bool ASTComparator::compare(yul::Leave const&, yul::Leave const&) +{ + return true; +} + +bool ASTComparator::compare(yul::BuiltinName const& _a, yul::BuiltinName const& _b) +{ + if (_a.handle != _b.handle) + return fail(fmt::format(R"(builtin function mismatch: "{}" vs "{}")", + m_dialect.builtin(_a.handle).name, m_dialect.builtin(_b.handle).name)); + return true; +} + +bool ASTComparator::compare(yul::FunctionCall const& _a, yul::FunctionCall const& _b) +{ + if (!compare(_a.functionName, _b.functionName)) + return false; + if (_a.arguments.size() != _b.arguments.size()) + return fail("argument count differs", _a, _b); + for (size_t i = 0; i < _a.arguments.size(); ++i) + { + Path path(*this, fmt::format("arg[{}]", i)); + if (!compare(_a.arguments[i], _b.arguments[i])) + return false; + } + return true; +} + +bool ASTComparator::compare(yul::Identifier const& _a, yul::Identifier const& _b) +{ + if (!m_bimap.tryMap(_a.name, _b.name)) + return fail(fmt::format(R"(identifier mapping inconsistent: "{}" vs "{}")", _a.name.str(), _b.name.str())); + return true; +} + +bool ASTComparator::compare(yul::Literal const& _a, yul::Literal const& _b) +{ + if (_a.kind != _b.kind) + return fail("literal kind mismatch"); + if (_a.value != _b.value) + return fail("literal value mismatch"); + return true; +} diff --git a/tools/yuldiff/ASTComparator.h b/tools/yuldiff/ASTComparator.h new file mode 100644 index 000000000000..cd009d99f5f3 --- /dev/null +++ b/tools/yuldiff/ASTComparator.h @@ -0,0 +1,142 @@ +/* + This file is part of solidity. + + solidity is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + solidity is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with solidity. If not, see . +*/ +// SPDX-License-Identifier: GPL-3.0 + +#pragma once + +#include + +#include +#include + +#include + +#include +#include +#include + +namespace solidity::tools::cmpast +{ + +class ASTComparator +{ +public: + + struct Mismatch + { + std::string path; + std::string reason; + std::string lhs; + std::string rhs; + }; + + class ComparisonResult + { + public: + static ComparisonResult equivalent() { return {}; } + static ComparisonResult failure(Mismatch _details) + { + ComparisonResult r; + r.m_mismatch = std::move(_details); + return r; + } + + explicit operator bool() const { return !m_mismatch.has_value(); } + + Mismatch const& mismatch() const + { + yulAssert(m_mismatch.has_value()); + return *m_mismatch; + } + + private: + std::optional m_mismatch; + }; + + explicit ASTComparator(yul::Dialect const& _dialect); + + ComparisonResult compareObjects(yul::Object const& _a, yul::Object const& _b); + +private: + struct Path + { + explicit Path(ASTComparator& _comparator, std::string _segment); + ~Path(); + + ASTComparator& m_comparator; + }; + + std::string currentPath() const; + + bool fail(std::string _reason); + + template + bool fail(std::string _reason, T const& _a, T const& _b) + { + yul::AsmPrinter printer(m_dialect, std::nullopt, langutil::DebugInfoSelection::None()); + m_mismatch = Mismatch{currentPath(), std::move(_reason), printer(_a), printer(_b)}; + return false; + } + + template + bool fail(std::string _reason, std::variant const& _a, std::variant const& _b) + { + yulAssert(_a.index() == _b.index()); + return std::visit([&](U const& _aInstance) { + return fail(_reason, _aInstance, std::get(_b)); + }, _a); + } + + template + bool compare(std::variant const& _a, std::variant const& _b) + { + if (_a.index() != _b.index()) + return fail(fmt::format("type mismatch (index {} vs {})", _a.index(), _b.index())); + + auto const same = std::visit([&](U const& _statementA) { + auto const& statementB = std::get(_b); + return compare(_statementA, statementB); + }, _a); + if (!same) + return false; + return true; + } + + bool compare(yul::Object const& _a, yul::Object const& _b); + bool compare(yul::Block const& _a, yul::Block const& _b); + bool compare(yul::ExpressionStatement const& _a, yul::ExpressionStatement const& _b); + bool compare(yul::Assignment const& _a, yul::Assignment const& _b); + bool compare(yul::VariableDeclaration const& _a, yul::VariableDeclaration const& _b); + bool compare(yul::FunctionDefinition const& _a, yul::FunctionDefinition const& _b); + bool compare(yul::If const& _a, yul::If const& _b); + bool compare(yul::Switch const& _a, yul::Switch const& _b); + bool compare(yul::ForLoop const& _a, yul::ForLoop const& _b); + static bool compare(yul::Break const& _a, yul::Break const& _b); + static bool compare(yul::Continue const& _a, yul::Continue const& _b); + static bool compare(yul::Leave const& _a, yul::Leave const& _b); + bool compare(yul::BuiltinName const& _a, yul::BuiltinName const& _b); + bool compare(yul::FunctionCall const& _a, yul::FunctionCall const& _b); + bool compare(yul::Identifier const& _a, yul::Identifier const& _b); + bool compare(yul::Literal const& _a, yul::Literal const& _b); + + yul::Dialect const& m_dialect; + ScopeBimap m_bimap; + std::vector m_pathStack; + std::optional m_mismatch; +}; + +} diff --git a/tools/yuldiff/CMakeLists.txt b/tools/yuldiff/CMakeLists.txt new file mode 100644 index 000000000000..677f0f37c40f --- /dev/null +++ b/tools/yuldiff/CMakeLists.txt @@ -0,0 +1,9 @@ +add_library(libyuldiff + ASTComparator.cpp + ASTComparator.h + ScopeBimap.h +) +target_link_libraries(libyuldiff PUBLIC solidity) + +add_executable(yuldiff main.cpp) +target_link_libraries(yuldiff PRIVATE libyuldiff) diff --git a/tools/yuldiff/ScopeBimap.h b/tools/yuldiff/ScopeBimap.h new file mode 100644 index 000000000000..eefb2f5fdab4 --- /dev/null +++ b/tools/yuldiff/ScopeBimap.h @@ -0,0 +1,79 @@ +/* + This file is part of solidity. + + solidity is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + solidity is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with solidity. If not, see . +*/ +// SPDX-License-Identifier: GPL-3.0 + +#pragma once + +#include + +#include +#include + +namespace solidity::tools::cmpast +{ + +class ScopeBimap +{ +public: + class Scope + { + public: + explicit Scope(ScopeBimap& _bimap): m_bimap{_bimap} + { + m_bimap.m_scopeStack.emplace_back(); + } + + ~Scope() + { + for (auto const& [l, r] : m_bimap.m_scopeStack.back()) + { + m_bimap.m_leftToRight.erase(l); + m_bimap.m_rightToLeft.erase(r); + } + m_bimap.m_scopeStack.pop_back(); + } + + private: + ScopeBimap& m_bimap; + }; + + /// Try to register a mapping l <-> r. + /// Returns true if consistent (either new or already matches). + bool tryMap(yul::YulName _l, yul::YulName _r) + { + auto itL = m_leftToRight.find(_l); + auto itR = m_rightToLeft.find(_r); + bool lMapped = itL != m_leftToRight.end(); + bool rMapped = itR != m_rightToLeft.end(); + + if (lMapped && rMapped) + return itL->second == _r && itR->second == _l; + if (lMapped || rMapped) + return false; // one side mapped but not to each other + + m_leftToRight[_l] = _r; + m_rightToLeft[_r] = _l; + m_scopeStack.back().emplace_back(_l, _r); + return true; + } + +private: + std::map m_leftToRight; + std::map m_rightToLeft; + std::vector>> m_scopeStack; +}; +} diff --git a/tools/yuldiff/main.cpp b/tools/yuldiff/main.cpp new file mode 100644 index 000000000000..01b6d92be53c --- /dev/null +++ b/tools/yuldiff/main.cpp @@ -0,0 +1,134 @@ +/* + This file is part of solidity. + + solidity is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + solidity is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with solidity. If not, see . +*/ +// SPDX-License-Identifier: GPL-3.0 + +/// Compares two Yul object trees structurally, treating variable and user-defined function names as equivalent +/// if they correspond 1:1 (tracked via a scoped bidirectional map). Prints a diff at the first point of divergence. + +#include + +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include + +#include +#include + +using namespace solidity; +using namespace solidity::yul; +using namespace solidity::langutil; + +static std::shared_ptr parseYulFile(std::string_view const _path) +{ + std::string source = util::readFileAsString(boost::filesystem::path(std::string(_path))); + Dialect const& dialect = EVMDialect::strictAssemblyForEVMObjects(EVMVersion::current(), std::nullopt); + ErrorList errors; + ErrorReporter errorReporter(errors); + auto const charStream = std::make_shared(source, std::string(_path)); + auto const scanner = std::make_shared(*charStream); + auto object = ObjectParser(errorReporter, dialect).parse(scanner, false); + if (!object || errorReporter.hasErrors()) + { + std::cerr << "Parse errors in " << _path << " (" << errors.size() << " error(s))\n"; + return nullptr; + } + return object; +} + +int main(int argc, char* argv[]) +{ + try + { + if (argc != 3) + { + std::cerr << "Usage: yulASTComparator \n"; + return EXIT_FAILURE; + } + + auto objA = parseYulFile(argv[1]); + auto objB = parseYulFile(argv[2]); + + if (!objA || !objB) + { + std::cerr << "Aborting due to parse errors.\n"; + return EXIT_FAILURE; + } + + Dialect const* dialect = objA->dialect(); + if (!dialect) + { + std::cerr << "No dialect available.\n"; + return EXIT_FAILURE; + } + + tools::cmpast::ASTComparator cmp(*dialect); + auto const result = cmp.compareObjects(*objA, *objB); + if (result) + { + std::cout << "EQUIVALENT\n"; + return EXIT_SUCCESS; + } + else + { + auto const& mm = result.mismatch(); + std::cout << "MISMATCH\n"; + std::cout << " at: " << mm.path << "\n"; + std::cout << " reason: " << mm.reason << "\n"; + if (!mm.lhs.empty()) + { + std::cout << "\n --- LHS ---\n" << mm.lhs << "\n"; + std::cout << "\n --- RHS ---\n" << mm.rhs << "\n"; + } + return EXIT_FAILURE; + } + } + catch (smtutil::SMTLogicError const& _exception) + { + std::cerr << "SMT logic error:" << std::endl; + std::cerr << boost::diagnostic_information(_exception); + return 2; + } + catch (InternalCompilerError const& _exception) + { + std::cerr << "Internal compiler error:" << std::endl; + std::cerr << boost::diagnostic_information(_exception); + return 2; + } + catch (YulAssertion const& _exception) + { + std::cerr << "Yul assertion failed:" << std::endl; + std::cerr << boost::diagnostic_information(_exception); + return 2; + } + catch (...) + { + std::cerr << "Uncaught exception:" << std::endl; + std::cerr << boost::current_exception_diagnostic_information() << std::endl; + return 2; + } +}