From cdd3b1a146b461176e2860588811ac7c99a387e8 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 28 Aug 2026 16:32:45 -0500 Subject: [PATCH 01/26] chore: remove dead intrinsic surface and unify the ABI version default Three related cleanups to the toolchain's intrinsic and ABI-version surface. 1. Remove the security-group API. CDT declared add_security_group_participants, remove_security_group_participants, in_active_security_group and get_active_security_group, but wire-sysio implements none of them -- `grep -rli security_group` over that tree matches zero files, so they are absent from genesis_intrinsics.cpp, the webassembly interface, the sys-vm registration and the OC intrinsic map. Because sysio_wasm_import turns a declaration straight into a WASM import, a contract calling one compiled and linked, then failed at deploy against an unknown import. Drops the two headers, the four native stubs and their intrinsics_def entries. 2. Prune 60 stale db_* names from cdt.imports.in. The legacy db_*_i64 and db_idx* intrinsics are gone from CDT (db.h is a stub forwarding to kv.h) and from the chain, so listing them in the --allow-undefined-file only delayed a diagnosis from link time to deploy time. The kv_* intrinsics that replaced them do not need an entry: the sysio_wasm_import attribute makes them imports rather than undefined symbols. 3. Unify the ABI version default. Three defaults disagreed -- abi.hpp had 1.1, compiler_options.hpp.in 1.2 and cdt-codegen 1.3 -- so a contract built through cdt-cpp got 1.2 while a standalone cdt-codegen run got 1.3. All four sites now take abi_version::default_major / default_minor from tools/include/sysio/abi.hpp, alongside protobuf_minor for the bump a protobuf_types section requires and a shared version_string() that replaces the "sysio::abi/" literal in three places. Fixes the version parser while there. Both drivers derived the minor by float round-trip, which truncated on any decimal without an exact binary expansion: -abi-version 1.3 emitted sysio::abi/1.2 and 1.4 emitted 1.3. The components are now parsed as integers by one shared abi_version::parse(), and malformed input gets a diagnostic and a non-zero exit instead of an uncaught std::stoi throw. Contract ABI output is unchanged: cdt-cpp already passed 1.2 explicitly, which is what the abigen-pass fixtures pin. abi_version_tests.sh gains coverage for the previously untested paths -- the toolchain baseline with no flag, the two versions the float arithmetic got wrong, and rejection of a malformed version. Also broadens the CLion build-dir ignore to cmake-build-*/ and ignores prequel's local review state. Full suite green: 29/29 ctest, including toolchain_tests and integration_tests. --- .gitignore | 6 +- imports/cdt.imports.in | 64 -------------- libraries/native/intrinsics.cpp | 16 ---- .../native/native/sysio/intrinsics_def.hpp | 5 -- .../sysiolib/capi/sysio/security_group.h | 56 ------------ .../contracts/sysio/security_group.hpp | 86 ------------------- tests/unit/abi_version_tests.sh | 64 ++++++++++++++ tools/codegen/cdt-codegen.cpp | 36 ++++---- tools/include/compiler_options.hpp.in | 13 +-- tools/include/sysio/abi.hpp | 80 ++++++++++++++++- tools/include/sysio/abimerge.hpp | 2 +- 11 files changed, 176 insertions(+), 252 deletions(-) delete mode 100644 libraries/sysiolib/capi/sysio/security_group.h delete mode 100644 libraries/sysiolib/contracts/sysio/security_group.hpp diff --git a/.gitignore b/.gitignore index 010f945d7..d761dcdab 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,8 @@ compile_commands.json [Bb]uild*/ .ccache/ .vcpkg-binary-cache/ -cmake-build-debug/ +# CLion-style build dirs: cmake-build-debug/, cmake-build-debug-vcpkg/, cmake-build-release/, ... +cmake-build-*/ examples/multi_index_example/build examples/hello/build @@ -67,3 +68,6 @@ tmp/ # oh-my-claudecode runtime state (operational artifacts, never committed) .omc/ + +# prequel local review state (operational artifacts, never committed) +.prequel/ diff --git a/imports/cdt.imports.in b/imports/cdt.imports.in index 3a3584e9b..179f3de6b 100644 --- a/imports/cdt.imports.in +++ b/imports/cdt.imports.in @@ -4,7 +4,6 @@ memcmp memset abort action_data_size -add_security_group_participants alt_bn128_add alt_bn128_mul alt_bn128_pair @@ -29,70 +28,9 @@ check_permission_authorization check_transaction_authorization current_receiver current_time -db_end_i64 -db_find_i64 -db_get_i64 -db_idx128_end -db_idx128_find_primary -db_idx128_find_secondary -db_idx128_lowerbound -db_idx128_next -db_idx128_previous -db_idx128_remove -db_idx128_store -db_idx128_update -db_idx128_upperbound -db_idx256_end -db_idx256_find_primary -db_idx256_find_secondary -db_idx256_lowerbound -db_idx256_next -db_idx256_previous -db_idx256_remove -db_idx256_store -db_idx256_update -db_idx256_upperbound -db_idx64_end -db_idx64_find_primary -db_idx64_find_secondary -db_idx64_lowerbound -db_idx64_next -db_idx64_previous -db_idx64_remove -db_idx64_store -db_idx64_update -db_idx64_upperbound -db_idx_double_end -db_idx_double_find_primary -db_idx_double_find_secondary -db_idx_double_lowerbound -db_idx_double_next -db_idx_double_previous -db_idx_double_remove -db_idx_double_store -db_idx_double_update -db_idx_double_upperbound -db_idx_long_double_end -db_idx_long_double_find_primary -db_idx_long_double_find_secondary -db_idx_long_double_lowerbound -db_idx_long_double_next -db_idx_long_double_previous -db_idx_long_double_remove -db_idx_long_double_store -db_idx_long_double_update -db_idx_long_double_upperbound -db_lowerbound_i64 -db_next_i64 -db_previous_i64 -db_remove_i64 -db_store_i64 -db_update_i64 -db_upperbound_i64 expiration get_action get_active_producers -get_active_security_group get_block_num get_blockchain_parameters_packed get_code_hash @@ -102,7 +40,6 @@ get_ram_usage get_resource_limits get_sender has_auth -in_active_security_group is_account is_feature_activated k1_recover @@ -124,7 +61,6 @@ publication_time read_action_data read_transaction recover_key -remove_security_group_participants require_auth require_auth2 require_recipient diff --git a/libraries/native/intrinsics.cpp b/libraries/native/intrinsics.cpp index 2b797e3ea..5c9c7c307 100644 --- a/libraries/native/intrinsics.cpp +++ b/libraries/native/intrinsics.cpp @@ -704,22 +704,6 @@ extern "C" { } #pragma clang diagnostic pop - int64_t add_security_group_participants(const char* data, uint32_t datalen) { - return intrinsics::get().call(data, datalen); - } - - int64_t remove_security_group_participants(const char* data, uint32_t datalen){ - return intrinsics::get().call(data, datalen); - } - - bool in_active_security_group(const char* data, uint32_t datalen){ - return intrinsics::get().call(data, datalen); - } - - uint32_t get_active_security_group(char* data, uint32_t datalen){ - return intrinsics::get().call(data, datalen); - } - void set_finalizers(uint64_t packed_finalizer_format, const char* data, uint32_t len) { intrinsics::get().call(packed_finalizer_format, data, len); } diff --git a/libraries/native/native/sysio/intrinsics_def.hpp b/libraries/native/native/sysio/intrinsics_def.hpp index 560d2ed70..1a536e19b 100644 --- a/libraries/native/native/sysio/intrinsics_def.hpp +++ b/libraries/native/native/sysio/intrinsics_def.hpp @@ -14,7 +14,6 @@ #include #include #include -#include #include #include @@ -102,10 +101,6 @@ intrinsic_macro(send_context_free_inline) \ intrinsic_macro(get_context_free_data) \ intrinsic_macro(get_sender) \ intrinsic_macro(set_action_return_value) \ -intrinsic_macro(add_security_group_participants) \ -intrinsic_macro(remove_security_group_participants) \ -intrinsic_macro(in_active_security_group) \ -intrinsic_macro(get_active_security_group) \ intrinsic_macro(blake2_f) \ intrinsic_macro(blake2b_256) \ intrinsic_macro(sha3) \ diff --git a/libraries/sysiolib/capi/sysio/security_group.h b/libraries/sysiolib/capi/sysio/security_group.h deleted file mode 100644 index acd53bc8f..000000000 --- a/libraries/sysiolib/capi/sysio/security_group.h +++ /dev/null @@ -1,56 +0,0 @@ -#pragma once -#include "types.h" -#ifdef __cplusplus -extern "C" { -#endif - -/** - * Propose new participants to the security group. - * - * @param data - the buffer containing the packed participants. - * @param datalen - size of the packed participants - * @pre `data` is a valid pointer to a range of memory at least `datalen` bytes long that contains packed participants data - * - * @return -1 if proposing a new security group was unsuccessful, otherwise returns 0. -*/ -__attribute__((sysio_wasm_import)) -int64_t add_security_group_participants(const char* data, uint32_t datalen); - -/** - * Propose to remove participants from the security group. - * - * @param data - the buffer containing the packed participants. - * @param datalen - size of the packed participants - * @pre `data` is a valid pointer to a range of memory at least `datalen` bytes long that contains packed participants data - * - * @return -1 if proposing a new security group was unsuccessful, otherwise returns 0. -*/ -__attribute__((sysio_wasm_import)) -int64_t remove_security_group_participants(const char* data, uint32_t datalen); - -/** - * Check if the specified accounts are all in the active security group. - * - * @param data - the buffer containing the packed participants. - * @param datalen - size of the packed participants - * - * @return Returns true if the specified accounts are all in the active security group. -*/ -__attribute__((sysio_wasm_import)) -bool in_active_security_group(const char* data, uint32_t datalen); - -/** - * Gets the active security group - * - * @param[out] data - the output buffer containing the packed security group. - * @param datalen - size of the `data` buffer - * - * @return Returns the size required in the buffer (if the buffer is too small, nothing is written). - * -*/ -__attribute__((sysio_wasm_import)) -uint32_t get_active_security_group(char* data, uint32_t datalen); - -#ifdef __cplusplus -} -#endif diff --git a/libraries/sysiolib/contracts/sysio/security_group.hpp b/libraries/sysiolib/contracts/sysio/security_group.hpp deleted file mode 100644 index 7cedc01b9..000000000 --- a/libraries/sysiolib/contracts/sysio/security_group.hpp +++ /dev/null @@ -1,86 +0,0 @@ -#pragma once -#include -#include "../../core/sysio/name.hpp" -#include "../../core/sysio/serialize.hpp" - -namespace sysio { - -namespace internal_use_do_not_use { -extern "C" { -__attribute__((sysio_wasm_import)) int64_t add_security_group_participants(const char* data, uint32_t datalen); - -__attribute__((sysio_wasm_import)) int64_t remove_security_group_participants(const char* data, uint32_t datalen); - -__attribute__((sysio_wasm_import)) bool in_active_security_group(const char* data, uint32_t datalen); - -__attribute__((sysio_wasm_import)) uint32_t get_active_security_group(char* data, uint32_t datalen); -} -} // namespace internal_use_do_not_use - -/** - * @defgroup security_group Security Group - * @ingroup contracts - * @brief Defines C++ security group API - */ - -struct security_group { - uint32_t version; - std::set participants; - CDT_REFLECT(version, participants); -}; - -/** - * Propose new participants to the security group. - * - * @ingroup security_group - * @param participants - the participants. - * - * @return -1 if proposing a new security group was unsuccessful, otherwise returns 0. - */ -inline int64_t add_security_group_participants(const std::set& participants) { - auto packed_participants = sysio::pack( participants ); - return internal_use_do_not_use::add_security_group_participants( packed_participants.data(), packed_participants.size() ); -} - -/** - * Propose to remove participants from the security group. - *å - * @ingroup security_group - * @param participants - the participants. - *å - * @return -1 if proposing a new security group was unsuccessful, otherwise returns 0. - */ -inline int64_t remove_security_group_participants(const std::set& participants){ - auto packed_participants = sysio::pack( participants ); - return internal_use_do_not_use::remove_security_group_participants( packed_participants.data(), packed_participants.size() ); -} - -/** - * Check if the specified accounts are all in the active security group. - * - * @ingroup security_group - * @param participants - the participants. - * - * @return Returns true if the specified accounts are all in the active security group. - */ -inline bool in_active_security_group(const std::set& participants){ - auto packed_participants = sysio::pack( participants ); - return internal_use_do_not_use::in_active_security_group( packed_participants.data(), packed_participants.size() ); -} - -/** - * Gets the active security group - * - * @ingroup security_group - * @param[out] packed_security_group - the buffer containing the packed security_group. - * - * @return Returns the size required in the buffer (if the buffer is too small, nothing is written). - * - */ -inline security_group get_active_security_group() { - size_t buffer_size = internal_use_do_not_use::get_active_security_group(0, 0); - std::vector buffer(buffer_size); - internal_use_do_not_use::get_active_security_group(buffer.data(), buffer_size); - return sysio::unpack(buffer); -} -} // namespace sysio \ No newline at end of file diff --git a/tests/unit/abi_version_tests.sh b/tests/unit/abi_version_tests.sh index 25231dd7c..cffb1ad7f 100755 --- a/tests/unit/abi_version_tests.sh +++ b/tests/unit/abi_version_tests.sh @@ -21,6 +21,8 @@ check() { fi } +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } + check_absent() { local desc="$1" file="$2" pattern="$3" if grep -q "$pattern" "$file" 2>/dev/null; then @@ -85,6 +87,68 @@ check "proto syntax is proto3" \ "${CONTRACTS_DIR}/pb_tests.abi" \ '"syntax": "proto3"' +# --- -abi-version parsing ------------------------------------------------------ +# +# The driver used to derive the minor by float round-trip: +# +# float tmp = std::stof(v); minor = (int)((tmp - (int)tmp) * 10); +# +# which truncates whenever the decimal has no exact binary expansion. "1.3" +# parsed as minor 2 and "1.4" as minor 3, so `-abi-version 1.3` silently emitted +# sysio::abi/1.2. Both driver and cdt-codegen now parse the components as +# integers from a single shared implementation (abi_version::parse). These cases +# pin that: they FAIL on the float arithmetic and pass on integer parsing. +echo "-- -abi-version parsing --" + +CDT_CPP="${BUILD_DIR}/bin/cdt-cpp" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +cat > "${WORK}/verparse.cpp" <<'CONTRACT' +#include +class [[sysio::contract("verparse")]] verparse : public sysio::contract { +public: + using contract::contract; + [[sysio::action]] void hi(sysio::name nm) { (void)nm; } +}; +CONTRACT + +check_emitted_version() { + local requested="$1" expected="$2" + local abi="${WORK}/v${expected}.abi" + local desc="-abi-version ${requested} emits sysio::abi/${expected}" + local args=(-abigen -contract verparse "-abigen_output=${abi}" + "${WORK}/verparse.cpp" -o "${WORK}/v${expected}.wasm") + [ -n "$requested" ] && args=(-abi-version "$requested" "${args[@]}") + + if ! "$CDT_CPP" "${args[@]}" > "${WORK}/build.log" 2>&1; then + fail "$desc (build failed)" + sed 's/^/ /' "${WORK}/build.log" + return + fi + check "$desc" "$abi" "\"version\": \"sysio::abi/${expected}\"" +} + +check_emitted_version "" "1.2" # no flag -> the toolchain baseline +check_emitted_version "1.2" "1.2" +check_emitted_version "1.3" "1.3" # float parse gave 1.2 here +check_emitted_version "1.4" "1.4" # float parse gave 1.3 here + +# A malformed version must be rejected with a diagnostic, not silently coerced. +if "$CDT_CPP" -abi-version "not-a-version" -abigen -contract verparse \ + "-abigen_output=${WORK}/bad.abi" "${WORK}/verparse.cpp" \ + -o "${WORK}/bad.wasm" > "${WORK}/bad.log" 2>&1; then + fail "malformed -abi-version is rejected" +else + if grep -q "invalid -abi-version" "${WORK}/bad.log"; then + echo " PASS: malformed -abi-version is rejected with a diagnostic" + PASS=$((PASS + 1)) + else + fail "malformed -abi-version rejected, but without the expected diagnostic" + sed 's/^/ /' "${WORK}/bad.log" + fi +fi + echo "" echo "Results: ${PASS} passed, ${FAIL} failed" [ "$FAIL" -eq 0 ] \ No newline at end of file diff --git a/tools/codegen/cdt-codegen.cpp b/tools/codegen/cdt-codegen.cpp index 22512f68c..28fbac09f 100644 --- a/tools/codegen/cdt-codegen.cpp +++ b/tools/codegen/cdt-codegen.cpp @@ -172,9 +172,9 @@ static std::string contract_name; static bool explicit_contract = false; static std::string output_dir = "."; -static std::string abi_version; -static int abi_version_major = 1; -static int abi_version_minor = 3; +static std::string abi_version_arg; +static int abi_version_major = abi_version::default_major; +static int abi_version_minor = abi_version::default_minor; static bool no_abigen = false; static std::string abi_output_path; // Link-time finalize split (see main()): @@ -234,7 +234,8 @@ static void print_usage(const char* prog) { << "\nOptions:\n" << " --contract NAME Contract name\n" << " --output-dir DIR Output directory (default: .)\n" - << " --abi-version VERSION ABI version (e.g. 1.3)\n" + << " --abi-version VERSION ABI version as [.] (default: " + << abi_version::default_spelling() << ")\n" << " --no-abigen Disable ABI generation\n" << " --cxx OPTIONS Additional C++ compiler options\n" << " -I, --include DIR C++ include directory (repeatable)\n" @@ -273,10 +274,13 @@ static void parse_args(int argc, const char** argv) { } else if (arg == "--output-dir" && i + 1 < argc) { output_dir = argv[++i]; } else if (arg == "--abi-version" && i + 1 < argc) { - abi_version = argv[++i]; - float tmp; - abi_version_major = std::stoi(abi_version); - abi_version_minor = (int)(std::modf(std::stof(abi_version), &tmp) * 10); + abi_version_arg = argv[++i]; + if (!abi_version::parse(abi_version_arg, abi_version_major, abi_version_minor)) { + std::cerr << "invalid --abi-version '" << abi_version_arg + << "': expected [.], e.g. " + << abi_version::default_spelling() << "\n"; + exit(1); + } } else if (arg == "--cxx" && i + 1 < argc) { cxx_arg = argv[++i]; } else if ((arg == "-I" || arg == "--include") && i + 1 < argc) { @@ -400,12 +404,12 @@ static void gen_actions(const std::string& input) { if (abigen_opts.size()) abigen_opts += ","; - if (abi_version.size()) { - abigen_opts += "abi_version=" + abi_version; + if (abi_version_arg.size()) { + abigen_opts += "abi_version=" + abi_version_arg; } else if (no_abigen) { abigen_opts += "no_abigen"; } else { - abigen_opts += "abi_version=1.3"; + abigen_opts += "abi_version=" + abi_version::default_spelling(); } if (suppress_ricardian_warnings) { @@ -697,10 +701,12 @@ int main(int argc, const char** argv) { abi["protobuf_types"] = ojson::parse(protobuf_types_json); - // Bump ABI version to 1.3 when protobuf_types section is present - if (abi_version_major == 1 && abi_version_minor < 3) { - abi_version_minor = 3; - abi["version"] = "sysio::abi/1.3"; + // The protobuf_types section is only understood from abi_version::protobuf_minor + // onwards, so a contract that emits one is bumped up to it. + if (abi_version_major == abi_version::default_major && + abi_version_minor < abi_version::protobuf_minor) { + abi_version_minor = abi_version::protobuf_minor; + abi["version"] = abi_version::version_string(abi_version_major, abi_version_minor); } } else if (referenced_pb_types.size()) { std::cerr << "protobuf types are used but no protobuf files are specified for contract " << contract_name diff --git a/tools/include/compiler_options.hpp.in b/tools/include/compiler_options.hpp.in index ad66fd9af..2b35a7596 100644 --- a/tools/include/compiler_options.hpp.in +++ b/tools/include/compiler_options.hpp.in @@ -1018,13 +1018,16 @@ static Options CreateOptions(bool add_defaults=true) { #endif - int abi_version_major = 1; - int abi_version_minor = 2; + int abi_version_major = abi_version::default_major; + int abi_version_minor = abi_version::default_minor; if (!abi_version_opt.empty()) { - abi_version_major = std::stoi(abi_version_opt); - float tmp = std::stof(abi_version_opt); - abi_version_minor = ((tmp - (int)tmp)*10); + if (!abi_version::parse(abi_version_opt, abi_version_major, abi_version_minor)) { + std::cerr << "invalid -abi-version '" << abi_version_opt + << "': expected [.], e.g. " + << abi_version::default_spelling() << "\n"; + exit(1); + } } #ifndef ONLY_LD diff --git a/tools/include/sysio/abi.hpp b/tools/include/sysio/abi.hpp index 874146324..98fcdc43f 100644 --- a/tools/include/sysio/abi.hpp +++ b/tools/include/sysio/abi.hpp @@ -7,6 +7,80 @@ #include #include +/** + * The ABI format version this toolchain emits. + * + * Single source of truth for every host tool and for the abigen plugin: cdt-cpp, + * cdt-cc, cdt-ld, cdt-codegen and `sysio_abigen` all take their default from here, + * so a standalone `cdt-codegen` run and an `add_contract()` build cannot stamp + * different versions into a contract's `.abi`. Bumping the format is a one-line + * change here plus a refresh of the `tests/toolchain/abigen-pass/*.abi` fixtures, + * which pin the emitted string byte-for-byte. + */ +namespace abi_version { + inline constexpr int default_major = 1; + inline constexpr int default_minor = 2; + + /// "." -- the spelling accepted by the `-abi-version` driver flag + /// and by the abigen plugin's `abi_version=` plugin argument. + inline std::string spelling(int major_v, int minor_v) { + return std::to_string(major_v) + "." + std::to_string(minor_v); + } + + inline std::string default_spelling() { return spelling(default_major, default_minor); } + + /// The minor from which the `protobuf_types` ABI section is understood. A + /// contract that emits one is bumped from the baseline to here; a contract that + /// does not stays at the baseline. + inline constexpr int protobuf_minor = 3; + + /// The full "sysio::abi/." string stamped into a contract's ABI. + inline std::string version_string(int major_v, int minor_v) { + return "sysio::abi/" + spelling(major_v, minor_v); + } + + /** + * Parse a "" or "." spelling. + * + * Integer parsing throughout: the previous float round-trip + * (`(int)((stof(v) - (int)stof(v)) * 10)`) truncated on any minor whose decimal + * expansion falls short in binary -- "1.3" parsed as minor 2 -- which silently + * desynced the version handed to the plugin from the one handed to ABIMerger. + * + * @param text the spelling to parse + * @param major_out set to the major component on success; untouched on failure + * @param minor_out set to the minor component on success (0 when omitted); + * untouched on failure + * @return true when @p text is a well-formed version, false otherwise. Callers + * are expected to emit a diagnostic and exit non-zero on false rather + * than proceeding with a partially-parsed version. + */ + inline bool parse(const std::string& text, int& major_out, int& minor_out) { + if (text.empty()) + return false; + + const auto dot = text.find('.'); + const std::string major_text = text.substr(0, dot); + const std::string minor_text = (dot == std::string::npos) ? std::string("0") + : text.substr(dot + 1); + + // Reject anything std::stoi would otherwise accept by prefix ("1x", " 1", "1.2.3"). + auto all_digits = [](const std::string& v) { + return !v.empty() && v.find_first_not_of("0123456789") == std::string::npos; + }; + if (!all_digits(major_text) || !all_digits(minor_text)) + return false; + + try { + major_out = std::stoi(major_text); + minor_out = std::stoi(minor_text); + } catch (const std::exception&) { + return false; // out of int range + } + return true; + } +} // namespace abi_version + struct abi_typedef { std::string new_type_name; std::string type; @@ -119,9 +193,9 @@ struct abi_action_result { /// From sysio libraries/chain/include/sysio/chain/abi_def.hpp struct abi { - int version_major = 1; - int version_minor = 1; - std::string version_string()const { return std::string("sysio::abi/")+std::to_string(version_major)+"."+std::to_string(version_minor); } + int version_major = abi_version::default_major; + int version_minor = abi_version::default_minor; + std::string version_string()const { return abi_version::version_string(version_major, version_minor); } std::set structs; std::set typedefs; std::set actions; diff --git a/tools/include/sysio/abimerge.hpp b/tools/include/sysio/abimerge.hpp index 1990b2a91..9ceb00120 100644 --- a/tools/include/sysio/abimerge.hpp +++ b/tools/include/sysio/abimerge.hpp @@ -17,7 +17,7 @@ class ABIMerger { ABIMerger(ojson a) : abi(a) {} ABIMerger(ojson a, int version_major, int version_minor) : abi(a) { if (abi.empty()) { - abi["version"] = std::string("sysio::abi/") + std::to_string(version_major) + "." + std::to_string(version_minor); + abi["version"] = abi_version::version_string(version_major, version_minor); abi["types"] = ojson::array(); abi["structs"] = ojson::array(); abi["actions"] = ojson::array(); From 705b29fd57ec8824eb489f10a278f401a1197dba Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 28 Aug 2026 17:41:56 -0500 Subject: [PATCH 02/26] =?UTF-8?q?chore:=20address=20review=20=E2=80=94=20p?= =?UTF-8?q?rune=20staged=20headers,=20finish=20the=20version=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings on #112, all confirmed against the code. Prune the staged include tree (was: deleting a header left it staged forever). Header staging was three configure-time file(COPY) calls, which are additive and never remove a copy whose source is gone. Because the CDTWasmLibraries configure step is stamped, reusing a build tree across a deletion never recovered: the removed header stayed in /include, shipped by install/CPack and visible to native consumers whose compiled view could disagree with the rebuilt library. Staging moves into cmake/stage_headers.cmake, run from a stage_cdt_headers build target, which prunes before it copies. One ordered script matters here: the destinations overlap -- sysiolib owns include/sysiolib and native's second copy lands in include/sysiolib/native, inside it -- so two independent steps would race to delete each other's output. file(COPY) preserves source timestamps and skips files already current, so re-running every build costs no downstream rebuilds. New staged_headers_tests.sh pins the invariant that every staged header still has a source counterpart, catching any future stale staging rather than just this one. Finish replacing float-based version handling. The previous commit fixed the two drivers but left two more parsers, so a two-digit minor was mangled three different ways: the abigen plugin's stof/modf read 1.10 as 1.1, and ABIMerger derived the version from the string's last three characters (".10" -> 0.10), which both picked the wrong version in merge_version and silently dropped action_results by failing its >= 12 gate. That suffix trick also mis-read any major >= 10 ("sysio::abi/10.2" -> "0.2"). The plugin now uses abi_version::parse. ABIMerger stores the version it was constructed with and compares parsed components, via a new abi_version::parse_version_string that ignores the namespace prefix -- so an inherited eosio::abi/1.2 descriptor parses like a sysio one. The gate constant becomes abi_version::action_results_minor. The single-argument ABIMerger constructor was dead and is removed, so a merger cannot exist without a version. Reject a zero major. parse() accepted 0.1, but cdt-cpp reads a zero major as "the option was never given", so cdt-cpp -abi-version 0.1 fell back to the default while cdt-codegen --abi-version 0.1 honoured it -- reopening the very divergence this PR closes. There is no ABI 0.x, so parse() rejects it, which retires the > 0 sentinel in cdt-cpp; the version is now always forwarded. Avoid `/*` inside the abi.hpp block comment, which tripped -Wcomment. abi_version_tests.sh gains the 1.10 round-trip, an assertion that 1.10 keeps action_results, and rejection of 0.1, 1.2.3 and 1x alongside the existing cases. Verified the upgrade path the review asked for: planted both removed headers back into an existing build tree, rebuilt without reconfiguring (CDTWasmLibraries' configure stamp untouched, 11 days old), and the build step pruned them. Full suite 30/30, including toolchain_tests, multidir_contract_tests -- which covers the sidecar agreement affected by always forwarding the version -- and integration_tests. --- cmake/stage_headers.cmake | 50 ++++++++++++++++++ libraries/CMakeLists.txt | 19 +++++++ libraries/native/CMakeLists.txt | 4 +- libraries/sysiolib/CMakeLists.txt | 2 +- plugins/sysio/abigen.hpp | 10 ++-- tests/CMakeLists.txt | 6 +++ tests/unit/abi_version_tests.sh | 41 +++++++++------ tests/unit/staged_headers_tests.sh | 82 ++++++++++++++++++++++++++++++ tools/cc/cdt-cpp.cpp.in | 11 ++-- tools/include/sysio/abi.hpp | 45 ++++++++++++++-- tools/include/sysio/abimerge.hpp | 33 +++++++++--- 11 files changed, 263 insertions(+), 40 deletions(-) create mode 100644 cmake/stage_headers.cmake create mode 100755 tests/unit/staged_headers_tests.sh diff --git a/cmake/stage_headers.cmake b/cmake/stage_headers.cmake new file mode 100644 index 000000000..f126b59fb --- /dev/null +++ b/cmake/stage_headers.cmake @@ -0,0 +1,50 @@ +# Stage the CDT-owned header trees into /include, pruning first. +# +# Run in script mode (`cmake -P`) from the `stage_cdt_headers` build target, not at +# configure time. Configure-time `file(COPY)` is additive: it never removes a staged +# copy whose source has been deleted, so a removed header stayed in /include +# forever -- shipped by install/CPack, and visible to native consumers whose compiled +# view could then disagree with the rebuilt library. Reusing a build tree across such +# a deletion is the case this exists to handle, and a configure-time copy cannot, +# because the ExternalProject's configure step is stamped and does not re-run. +# +# The two destinations OVERLAP: sysiolib owns include/sysiolib, and native's second +# copy lands in include/sysiolib/native, a subdirectory of it. Pruning and copying +# from one ordered script is what makes that safe -- two independent steps would race +# to delete each other's output. +# +# `file(COPY)` preserves source timestamps and skips files already current at the +# destination, so re-running every build neither churns mtimes nor triggers +# downstream rebuilds. +# +# Inputs (via -D): +# STAGE_SOURCE_DIR - the repo's libraries/ directory +# STAGE_BINARY_DIR - BASE_BINARY_DIR, whose include/ subtree is staged into +# STAGE_NATIVE - truthy when ENABLE_NATIVE_COMPILER is on + +foreach(var STAGE_SOURCE_DIR STAGE_BINARY_DIR) + if(NOT DEFINED ${var}) + message(FATAL_ERROR "stage_headers.cmake: ${var} is required") + endif() +endforeach() + +set(header_patterns FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp") + +# sysiolib -> include/sysiolib. Pruned first, before native repopulates its subtree. +file(REMOVE_RECURSE "${STAGE_BINARY_DIR}/include/sysiolib") +file(COPY "${STAGE_SOURCE_DIR}/sysiolib" + DESTINATION "${STAGE_BINARY_DIR}/include" + ${header_patterns}) + +if(STAGE_NATIVE) + # native -> include/sysio/native + file(REMOVE_RECURSE "${STAGE_BINARY_DIR}/include/sysio/native") + file(COPY "${STAGE_SOURCE_DIR}/native" + DESTINATION "${STAGE_BINARY_DIR}/include/sysio" + ${header_patterns} PATTERN "softfloat" EXCLUDE) + + # native/native -> include/sysiolib/native (inside the tree pruned above) + file(COPY "${STAGE_SOURCE_DIR}/native/native" + DESTINATION "${STAGE_BINARY_DIR}/include/sysiolib" + ${header_patterns} PATTERN "softfloat" EXCLUDE) +endif() diff --git a/libraries/CMakeLists.txt b/libraries/CMakeLists.txt index 3bd42e7bf..95397ff88 100644 --- a/libraries/CMakeLists.txt +++ b/libraries/CMakeLists.txt @@ -19,6 +19,18 @@ endif() set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_EXTENSIONS ON) +# Stage the CDT-owned headers into ${BASE_BINARY_DIR}/include at BUILD time, pruning +# any whose source has been deleted. See cmake/stage_headers.cmake for why this cannot +# be a configure-time file(COPY). +add_custom_target(stage_cdt_headers ALL + COMMAND ${CMAKE_COMMAND} + -DSTAGE_SOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR} + -DSTAGE_BINARY_DIR=${BASE_BINARY_DIR} + -DSTAGE_NATIVE=$ + -P ${CMAKE_CURRENT_SOURCE_DIR}/../cmake/stage_headers.cmake + COMMENT "Staging CDT headers into ${BASE_BINARY_DIR}/include" + VERBATIM) + add_subdirectory(libc) add_subdirectory(libc++) add_subdirectory(sysiolib) @@ -28,5 +40,12 @@ if (ENABLE_NATIVE_COMPILER) add_subdirectory(native) endif() +# Anything compiled against the staged tree must see the pruned copy, not a leftover. +foreach(staged_consumer sysio native native_sysio) + if(TARGET ${staged_consumer}) + add_dependencies(${staged_consumer} stage_cdt_headers) + endif() +endforeach() + file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/boost/include/boost/preprocessor DESTINATION ${BASE_BINARY_DIR}/include/boost) file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/meta_refl/include/bluegrass DESTINATION ${BASE_BINARY_DIR}/include) diff --git a/libraries/native/CMakeLists.txt b/libraries/native/CMakeLists.txt index 8941e8939..9788f7209 100644 --- a/libraries/native/CMakeLists.txt +++ b/libraries/native/CMakeLists.txt @@ -364,6 +364,4 @@ add_custom_command( TARGET native POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy $< add_custom_command( TARGET sf POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy $ ${BASE_BINARY_DIR}/lib ) -file(COPY ${CMAKE_CURRENT_SOURCE_DIR} DESTINATION ${BASE_BINARY_DIR}/include/sysio FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp" PATTERN "softfloat" EXCLUDE) - -file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/native DESTINATION ${BASE_BINARY_DIR}/include/sysiolib FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp" PATTERN "softfloat" EXCLUDE) +# Header staging lives in the stage_cdt_headers target (cmake/stage_headers.cmake). diff --git a/libraries/sysiolib/CMakeLists.txt b/libraries/sysiolib/CMakeLists.txt index 1d014d1f0..d0a5944c0 100644 --- a/libraries/sysiolib/CMakeLists.txt +++ b/libraries/sysiolib/CMakeLists.txt @@ -48,4 +48,4 @@ if (ENABLE_NATIVE_COMPILER) add_custom_command( TARGET native_sysio POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy $ ${BASE_BINARY_DIR}/lib ) endif() -file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/../sysiolib DESTINATION ${BASE_BINARY_DIR}/include FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp") +# Header staging lives in the stage_cdt_headers target (cmake/stage_headers.cmake). diff --git a/plugins/sysio/abigen.hpp b/plugins/sysio/abigen.hpp index 5e7882a40..ea8b7ab40 100644 --- a/plugins/sysio/abigen.hpp +++ b/plugins/sysio/abigen.hpp @@ -1461,9 +1461,13 @@ namespace sysio { namespace cdt { output = arg.substr(arg.find("=")+1); } else if (sysio::cdt::starts_with(arg, "abi_version=")) { auto str = arg.substr(arg.find("=")+1); - float tmp; - int abi_version_major = std::stoi(str); - int abi_version_minor = (int)(std::modf(std::stof(str), &tmp) * 10); + int abi_version_major = abi_version::default_major; + int abi_version_minor = abi_version::default_minor; + if (!abi_version::parse(str, abi_version_major, abi_version_minor)) { + llvm::errs() << "sysio_abigen: invalid abi_version '" << str + << "': expected [.]\n"; + return false; + } abigen::get().set_abi_version(abi_version_major, abi_version_minor); } else if (arg == "no_abigen") { abigen::get().no_abigen = true; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0ed05d0bd..2a15a3cb4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -40,6 +40,12 @@ configure_file(${CMAKE_CURRENT_SOURCE_DIR}/unit/multidir_contract_tests.sh ${CMA add_test(NAME multidir_contract_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/multidir_contract_tests.sh "${CMAKE_BINARY_DIR}" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) set_property(TEST multidir_contract_tests PROPERTY LABELS unit_tests) +# Staged-header hygiene — every header under /include must still exist in +# libraries/. Catches a stale copy left behind when a source header is deleted. +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/unit/staged_headers_tests.sh ${CMAKE_BINARY_DIR}/tests/unit/staged_headers_tests.sh COPYONLY) +add_test(NAME staged_headers_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/staged_headers_tests.sh "${CMAKE_BINARY_DIR}" "${CMAKE_SOURCE_DIR}" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) +set_property(TEST staged_headers_tests PROPERTY LABELS unit_tests) + # sysio-pp (WABT post-pass) regression — guards the WSA-020 / SEC-10 # FillFromSegments out-of-bounds write and basic post-pass correctness. configure_file(${CMAKE_CURRENT_SOURCE_DIR}/unit/postpass_tests.sh ${CMAKE_BINARY_DIR}/tests/unit/postpass_tests.sh COPYONLY) diff --git a/tests/unit/abi_version_tests.sh b/tests/unit/abi_version_tests.sh index cffb1ad7f..e5fbfe782 100755 --- a/tests/unit/abi_version_tests.sh +++ b/tests/unit/abi_version_tests.sh @@ -129,25 +129,36 @@ check_emitted_version() { check "$desc" "$abi" "\"version\": \"sysio::abi/${expected}\"" } -check_emitted_version "" "1.2" # no flag -> the toolchain baseline -check_emitted_version "1.2" "1.2" -check_emitted_version "1.3" "1.3" # float parse gave 1.2 here -check_emitted_version "1.4" "1.4" # float parse gave 1.3 here - -# A malformed version must be rejected with a diagnostic, not silently coerced. -if "$CDT_CPP" -abi-version "not-a-version" -abigen -contract verparse \ - "-abigen_output=${WORK}/bad.abi" "${WORK}/verparse.cpp" \ - -o "${WORK}/bad.wasm" > "${WORK}/bad.log" 2>&1; then - fail "malformed -abi-version is rejected" -else - if grep -q "invalid -abi-version" "${WORK}/bad.log"; then - echo " PASS: malformed -abi-version is rejected with a diagnostic" +check_emitted_version "" "1.2" # no flag -> the toolchain baseline +check_emitted_version "1.2" "1.2" +check_emitted_version "1.3" "1.3" # float parse gave 1.2 here +check_emitted_version "1.4" "1.4" # float parse gave 1.3 here +check_emitted_version "1.10" "1.10" # two-digit minor: see below + +# A two-digit minor exercised three separate parsers, each of which got it wrong: +# the driver's stof/modf, the abigen plugin's stof/modf, and ABIMerger deriving the +# version from the string's last three characters (".10" -> 0.10). The last one also +# silently dropped action_results, because 0.10*10 failed its >= 12 gate. All three +# now share abi_version::parse / parse_version_string, so the section must survive. +check "1.10 keeps action_results (ABIMerger no longer parses the suffix)" \ + "${WORK}/v1.10.abi" \ + '"action_results"' + +# There is no ABI 0.x, and cdt-cpp reads a zero major as "option absent" -- accepting +# one would reopen the driver/codegen divergence. Must be rejected, not coerced. +for bad in "not-a-version" "0.1" "1.2.3" "1x"; do + if "$CDT_CPP" -abi-version "$bad" -abigen -contract verparse \ + "-abigen_output=${WORK}/bad.abi" "${WORK}/verparse.cpp" \ + -o "${WORK}/bad.wasm" > "${WORK}/bad.log" 2>&1; then + fail "-abi-version ${bad} is rejected" + elif grep -q "invalid -abi-version" "${WORK}/bad.log"; then + echo " PASS: -abi-version ${bad} is rejected with a diagnostic" PASS=$((PASS + 1)) else - fail "malformed -abi-version rejected, but without the expected diagnostic" + fail "-abi-version ${bad} rejected, but without the expected diagnostic" sed 's/^/ /' "${WORK}/bad.log" fi -fi +done echo "" echo "Results: ${PASS} passed, ${FAIL} failed" diff --git a/tests/unit/staged_headers_tests.sh b/tests/unit/staged_headers_tests.sh new file mode 100755 index 000000000..9559d30e9 --- /dev/null +++ b/tests/unit/staged_headers_tests.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Guards the staged header tree under /include against stale files. +# +# Header staging used to be a configure-time file(COPY), which is additive: deleting a +# source header left the staged copy behind forever, so install/CPack kept shipping a +# removed API and native consumers could compile against a header that disagreed with +# the rebuilt library. Reusing a build tree across such a deletion never recovered, +# because the ExternalProject's configure step is stamped and does not re-run. +# +# stage_cdt_headers (cmake/stage_headers.cmake) now prunes before it copies. This test +# pins the resulting invariant -- every staged header has a source counterpart -- so it +# catches ANY future stale staging, not just the deletion that prompted it. +# +# Usage: staged_headers_tests.sh +set -euo pipefail + +BUILD_DIR="$1" +SOURCE_DIR="$2" +INCLUDE_DIR="${BUILD_DIR}/include" +PASS=0 +FAIL=0 + +pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } + +# Map a staged path back to the source file it was copied from. The three staged trees +# come from two source trees, and one destination nests inside another: +# +# include/sysiolib/native/ <- libraries/native/native/ +# include/sysiolib/ <- libraries/sysiolib/ +# include/sysio/native/ <- libraries/native/ +# +# The sysiolib/native rule is tested first because it is the more specific prefix. +source_for() { + local staged="$1" + case "$staged" in + sysiolib/native/*) echo "${SOURCE_DIR}/libraries/native/native/${staged#sysiolib/native/}" ;; + sysiolib/*) echo "${SOURCE_DIR}/libraries/${staged}" ;; + sysio/native/*) echo "${SOURCE_DIR}/libraries/native/${staged#sysio/native/}" ;; + *) echo "" ;; + esac +} + +echo "=== Staged Header Tests ===" + +if [ ! -d "$INCLUDE_DIR" ]; then + fail "staged include directory exists (${INCLUDE_DIR})" + echo "Results: ${PASS} passed, ${FAIL} failed" + exit 1 +fi + +# Only the CDT-owned trees are checked. The vendored trees (libc, libcxx, boost, +# bluegrass) are still staged by their own configure-time copies and are out of scope. +staged_count=0 +stale=() +while IFS= read -r abs; do + rel="${abs#${INCLUDE_DIR}/}" + src="$(source_for "$rel")" + [ -z "$src" ] && continue + staged_count=$((staged_count + 1)) + [ -f "$src" ] || stale+=("$rel") +done < <(find "${INCLUDE_DIR}/sysiolib" "${INCLUDE_DIR}/sysio" \ + \( -name '*.h' -o -name '*.hpp' \) -type f 2>/dev/null) + +if [ "$staged_count" -eq 0 ]; then + fail "found staged CDT headers to check (none under ${INCLUDE_DIR})" +else + pass "found ${staged_count} staged CDT headers" +fi + +if [ "${#stale[@]}" -eq 0 ]; then + pass "every staged header has a source counterpart" +else + fail "every staged header has a source counterpart" + echo " ${#stale[@]} staged header(s) no longer exist in libraries/:" + for f in "${stale[@]}"; do echo " include/${f}"; done + echo " stage_cdt_headers should have pruned these; see cmake/stage_headers.cmake" +fi + +echo "" +echo "Results: ${PASS} passed, ${FAIL} failed" +[ "$FAIL" -eq 0 ] diff --git a/tools/cc/cdt-cpp.cpp.in b/tools/cc/cdt-cpp.cpp.in index 178066790..59789228e 100644 --- a/tools/cc/cdt-cpp.cpp.in +++ b/tools/cc/cdt-cpp.cpp.in @@ -66,8 +66,9 @@ static bool write_finalize_manifest(const Options& opts, const std::string& outp << fm::k_protobuf_files << "=" << opts.protobuf_files << "\n"; if (!opts.abigen_output.empty() && opts.abigen_output != "''") ss << fm::k_abi_output << "=" << opts.abigen_output << "\n"; - if (opts.abi_version.first > 0) - ss << fm::k_abi_version << "=" << opts.abi_version.first << "." << opts.abi_version.second << "\n"; + // Always recorded: abi_version::parse rejects a zero major and the default is + // non-zero, so there is no "unset" state left for a >0 test to stand in for. + ss << fm::k_abi_version << "=" << opts.abi_version.first << "." << opts.abi_version.second << "\n"; // The descriptor(s) this object produced -- cdt-codegen names each ..desc in the // output dir. One line per source (normally a single source per compile). These ARE per-TU: // cdt-ld accumulates every object's desc_file (sources in different subdirectories are all @@ -150,10 +151,8 @@ int main(int argc, const char **argv) { codegen_args.push_back(opts.abigen_output); } - if (opts.abi_version.first > 0) { - codegen_args.push_back("--abi-version"); - codegen_args.push_back(std::to_string(opts.abi_version.first) + "." + std::to_string(opts.abi_version.second)); - } + codegen_args.push_back("--abi-version"); + codegen_args.push_back(std::to_string(opts.abi_version.first) + "." + std::to_string(opts.abi_version.second)); // Only suppress ABI generation when explicitly not linking (compile-only mode) // and abigen was not requested. When linking, always generate ABI. diff --git a/tools/include/sysio/abi.hpp b/tools/include/sysio/abi.hpp index 98fcdc43f..9624483e2 100644 --- a/tools/include/sysio/abi.hpp +++ b/tools/include/sysio/abi.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -14,8 +15,8 @@ * cdt-cc, cdt-ld, cdt-codegen and `sysio_abigen` all take their default from here, * so a standalone `cdt-codegen` run and an `add_contract()` build cannot stamp * different versions into a contract's `.abi`. Bumping the format is a one-line - * change here plus a refresh of the `tests/toolchain/abigen-pass/*.abi` fixtures, - * which pin the emitted string byte-for-byte. + * change here plus a refresh of the `tests/toolchain/abigen-pass/.abi` + * fixtures, which pin the emitted string byte-for-byte. */ namespace abi_version { inline constexpr int default_major = 1; @@ -34,6 +35,10 @@ namespace abi_version { /// does not stays at the baseline. inline constexpr int protobuf_minor = 3; + /// The minor from which `action_results` is part of the format. ABIMerger only + /// merges that section for a document at or above this version. + inline constexpr int action_results_minor = 2; + /// The full "sysio::abi/." string stamped into a contract's ABI. inline std::string version_string(int major_v, int minor_v) { return "sysio::abi/" + spelling(major_v, minor_v); @@ -47,6 +52,12 @@ namespace abi_version { * expansion falls short in binary -- "1.3" parsed as minor 2 -- which silently * desynced the version handed to the plugin from the one handed to ABIMerger. * + * A zero major is rejected: there is no ABI 0.x, and cdt-cpp reads a zero major + * as "the option was never given" (tools/cc/cdt-cpp.cpp.in), so accepting one + * would let `cdt-cpp -abi-version 0.1` fall back to the default while + * `cdt-codegen --abi-version 0.1` honoured it -- reintroducing exactly the + * divergence this namespace exists to remove. + * * @param text the spelling to parse * @param major_out set to the major component on success; untouched on failure * @param minor_out set to the minor component on success (0 when omitted); @@ -71,14 +82,40 @@ namespace abi_version { if (!all_digits(major_text) || !all_digits(minor_text)) return false; + int major_v = 0; + int minor_v = 0; try { - major_out = std::stoi(major_text); - minor_out = std::stoi(minor_text); + major_v = std::stoi(major_text); + minor_v = std::stoi(minor_text); } catch (const std::exception&) { return false; // out of int range } + if (major_v == 0) + return false; + + major_out = major_v; + minor_out = minor_v; return true; } + + /** + * Parse the "::abi/." string stamped into a contract's ABI. + * + * The namespace prefix is not inspected, so a descriptor carrying an inherited + * `eosio::abi/1.2` parses the same as a `sysio::abi/1.2` one. Everything up to + * and including the last '/' is dropped and the remainder handed to parse(); + * a string with no '/' is parsed whole. + * + * @param text the version string to parse + * @param major_out set to the major component on success; untouched on failure + * @param minor_out set to the minor component on success; untouched on failure + * @return true when @p text carries a well-formed version, false otherwise + */ + inline bool parse_version_string(const std::string& text, int& major_out, int& minor_out) { + const auto slash = text.rfind('/'); + return parse(slash == std::string::npos ? text : text.substr(slash + 1), + major_out, minor_out); + } } // namespace abi_version struct abi_typedef { diff --git a/tools/include/sysio/abimerge.hpp b/tools/include/sysio/abimerge.hpp index 9ceb00120..7c1d96973 100644 --- a/tools/include/sysio/abimerge.hpp +++ b/tools/include/sysio/abimerge.hpp @@ -7,6 +7,7 @@ #include "abi.hpp" #include +#include #include using jsoncons::json; @@ -14,8 +15,10 @@ using jsoncons::ojson; class ABIMerger { public: - ABIMerger(ojson a) : abi(a) {} - ABIMerger(ojson a, int version_major, int version_minor) : abi(a) { + ABIMerger(ojson a, int version_major, int version_minor) + : abi(a) + , major(version_major) + , minor(version_minor) { if (abi.empty()) { abi["version"] = abi_version::version_string(version_major, version_minor); abi["types"] = ojson::array(); @@ -48,8 +51,12 @@ class ABIMerger { ret["tables"] = merge_tables(other); ret["ricardian_clauses"] = merge_clauses(other); ret["variants"] = merge_variants(other); - std::string vers = abi["version"].as(); - if (std::stod(vers.substr(vers.size()-3))*10 >= 12) { + // action_results entered the format at abi_version::action_results_minor. + // Compare parsed components: deriving them from the string's last three + // characters mis-read any two-digit minor ("sysio::abi/1.10" -> ".10") and + // any major >= 10 ("sysio::abi/10.2" -> "0.2"). + if (version_of(abi) >= std::pair{abi_version::default_major, + abi_version::action_results_minor}) { ret["action_results"] = merge_action_results(other); } { @@ -60,11 +67,19 @@ class ABIMerger { return ret; } private: + /// The (major, minor) a document declares, falling back to this merger's own + /// version when it carries none or an unparsable one. + std::pair version_of(const ojson& doc) const { + int major_v = major; + int minor_v = minor; + if (doc.has_key("version")) + abi_version::parse_version_string(doc["version"].as(), major_v, minor_v); + return {major_v, minor_v}; + } + std::string merge_version(ojson b) { - std::string ver_a = abi["version"].as(); - std::string ver_b = b["version"].as(); - return std::stod(ver_a.substr(ver_a.size()-3))*10 < std::stod(ver_b.substr(ver_b.size()-3))*10 ? - ver_b : ver_a; + return version_of(abi) < version_of(b) ? b["version"].as() + : abi["version"].as(); } static bool struct_is_same(ojson a, ojson b) { @@ -243,5 +258,7 @@ class ABIMerger { } ojson abi; + int major = abi_version::default_major; ///< version this merger was constructed for + int minor = abi_version::default_minor; }; #pragma GCC diagnostic pop From 2377cb5dd529efbb1b98ed6697188d4ab50cecff Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 31 Aug 2026 09:54:21 -0500 Subject: [PATCH 03/26] =?UTF-8?q?chore:=20address=20second=20review=20?= =?UTF-8?q?=E2=80=94=20one=20capability=20predicate,=20unconditional=20pru?= =?UTF-8?q?ne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six further findings, all confirmed. Version capabilities were decided in three places that disagreed. abigen's to_json emitted action_results only for major == 1, the merger ranked any higher major above 1.2, and cdt-abidiff scored the version with the same stod-over-the-last-three-characters trick this PR removed elsewhere -- reading "sysio::abi/1.10" as 1 and so skipping both its variant and action-result diffs. There is now one predicate, abi_version::supports_action_results (and supports_variants), used by the producer, the merger and abidiff alike. parse() also bounds the major at abi_version::max_supported_major. to_json only knows the 1.x shape, so accepting 2.0 or 10.2 stamped a version whose implied sections the toolchain then omitted. The merger gated on the wrong document. ret["version"] is the newer of the two, but the gate consulted only the left-hand side, so merging a 1.10 descriptor into a 1.1 accumulator emitted 1.10 while dropping the action_results the newer side carried. The merged version is computed once and used for both. Protobuf promotion happened too late. The abigen plugin had already run by the time the version was raised to 1.3, so --abi-version 1.1 plus protobuf made the plugin suppress action_results under its own 1.2 gate and codegen then stamped the incomplete result as 1.3. The promotion moves into main(), before gen_actions hands the version to the plugin -- and into main() specifically because the finalize pass never calls gen_actions, which an assertion caught. Staging pruned the native tree only when native mode was on, so a reused tree whose ENABLE_NATIVE_COMPILER went ON -> OFF kept the previous build's native headers, and InstallCDT.cmake installs the whole include tree. Both trees are now pruned unconditionally, before either is repopulated. package-tgz had no dependency on the nested libraries build that now does the pruning, so packaging a reused tree could ship a header a prior build deleted. It depends on CDTWasmLibraries. Tests: new abidiff_tests.sh pins the 1.10 gates and a version-only difference; abi_version_tests gains 2.0/10.2 rejection and a 1.1-plus-protobuf case asserting both the 1.3 stamp and the surviving non-void action result; staged_headers_tests takes ENABLE_NATIVE_COMPILER and asserts the native tree is absent when off. Full suite 31/31. --- cmake/package.cmake | 6 ++ cmake/stage_headers.cmake | 11 +++- plugins/sysio/abigen.hpp | 2 +- tests/CMakeLists.txt | 10 ++- tests/unit/abi_version_tests.sh | 41 +++++++++++- tests/unit/abidiff_tests.sh | 100 +++++++++++++++++++++++++++++ tests/unit/staged_headers_tests.sh | 28 +++++++- tools/abidiff/cdt-abidiff.cpp.in | 26 ++++++-- tools/codegen/cdt-codegen.cpp | 27 ++++++-- tools/include/sysio/abi.hpp | 33 +++++++++- tools/include/sysio/abimerge.hpp | 12 ++-- 11 files changed, 268 insertions(+), 28 deletions(-) create mode 100755 tests/unit/abidiff_tests.sh diff --git a/cmake/package.cmake b/cmake/package.cmake index ab18e443f..858d5572f 100644 --- a/cmake/package.cmake +++ b/cmake/package.cmake @@ -178,11 +178,17 @@ set(CPACK_WIRE_PUBLIC_ENTRY_POINTS "${CDT_PUBLIC_ENTRY_POINTS}") # versioned name on its own (see the archive-name/root decoupling in # cmake/cpack-project-config.cmake), so this no longer renames anything -- it is # a plain alias, kept because CI and the docs invoke it by name. +# Depends on CDTWasmLibraries because header staging (and its pruning) happens in that +# nested build -- see cmake/stage_headers.cmake. The generated `package` and `install` +# targets are ordered after `all` and so pick it up for free, but this convenience +# target is standalone: without the dependency, `cmake --build . --target package-tgz` +# on a reused tree could run CPack over a stale staged header a prior build deleted. add_custom_target(package-tgz COMMAND "${CMAKE_CPACK_COMMAND}" -G TGZ WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" COMMENT "Packaging ${CPACK_PACKAGE_FILE_NAME}.tar.gz (portable toolchain)" VERBATIM) +add_dependencies(package-tgz CDTWasmLibraries) set(CPACK_SET_DESTDIR OFF) set(CPACK_PACKAGE_RELOCATABLE OFF) diff --git a/cmake/stage_headers.cmake b/cmake/stage_headers.cmake index f126b59fb..1e525bee5 100644 --- a/cmake/stage_headers.cmake +++ b/cmake/stage_headers.cmake @@ -30,15 +30,22 @@ endforeach() set(header_patterns FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp") -# sysiolib -> include/sysiolib. Pruned first, before native repopulates its subtree. +# Prune BOTH trees before either is repopulated, and prune the native tree whether or +# not native mode is on. Pruning it inside the STAGE_NATIVE branch left the previous +# build's native headers staged when a reused tree flipped ENABLE_NATIVE_COMPILER from +# ON to OFF -- and since InstallCDT.cmake installs the whole include tree, an OFF build +# then packaged an API it was configured not to build. (include/sysiolib/native happens +# to vanish with its parent; include/sysio/native has no such parent.) file(REMOVE_RECURSE "${STAGE_BINARY_DIR}/include/sysiolib") +file(REMOVE_RECURSE "${STAGE_BINARY_DIR}/include/sysio/native") + +# sysiolib -> include/sysiolib file(COPY "${STAGE_SOURCE_DIR}/sysiolib" DESTINATION "${STAGE_BINARY_DIR}/include" ${header_patterns}) if(STAGE_NATIVE) # native -> include/sysio/native - file(REMOVE_RECURSE "${STAGE_BINARY_DIR}/include/sysio/native") file(COPY "${STAGE_SOURCE_DIR}/native" DESTINATION "${STAGE_BINARY_DIR}/include/sysio" ${header_patterns} PATTERN "softfloat" EXCLUDE) diff --git a/plugins/sysio/abigen.hpp b/plugins/sysio/abigen.hpp index ea8b7ab40..eb1f4c715 100644 --- a/plugins/sysio/abigen.hpp +++ b/plugins/sysio/abigen.hpp @@ -1072,7 +1072,7 @@ namespace sysio { namespace cdt { o["variants"].push_back(variant_to_json( v )); } o["abi_extensions"] = ojson::array(); - if (_abi.version_major == 1 && _abi.version_minor >= 2) { + if (abi_version::supports_action_results(_abi.version_major, _abi.version_minor)) { o["action_results"] = ojson::array(); for ( auto ar : _abi.action_results ) { o["action_results"].push_back(action_result_to_json( ar )); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2a15a3cb4..d54025ddb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -33,17 +33,23 @@ add_test(NAME version_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/version_tests set_property(TEST version_tests PROPERTY LABELS unit_tests) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/unit/abi_version_tests.sh ${CMAKE_BINARY_DIR}/tests/unit/abi_version_tests.sh COPYONLY) -add_test(NAME abi_version_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/abi_version_tests.sh "${CMAKE_BINARY_DIR}" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) +add_test(NAME abi_version_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/abi_version_tests.sh "${CMAKE_BINARY_DIR}" "${CMAKE_SOURCE_DIR}" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) set_property(TEST abi_version_tests PROPERTY LABELS unit_tests) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/unit/multidir_contract_tests.sh ${CMAKE_BINARY_DIR}/tests/unit/multidir_contract_tests.sh COPYONLY) add_test(NAME multidir_contract_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/multidir_contract_tests.sh "${CMAKE_BINARY_DIR}" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) set_property(TEST multidir_contract_tests PROPERTY LABELS unit_tests) +# cdt-abidiff ABI-version handling — the capability gates must use parsed components, +# not a fixed-width suffix read that mis-scores a two-digit minor. +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/unit/abidiff_tests.sh ${CMAKE_BINARY_DIR}/tests/unit/abidiff_tests.sh COPYONLY) +add_test(NAME abidiff_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/abidiff_tests.sh "${CMAKE_BINARY_DIR}/bin" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) +set_property(TEST abidiff_tests PROPERTY LABELS unit_tests) + # Staged-header hygiene — every header under /include must still exist in # libraries/. Catches a stale copy left behind when a source header is deleted. configure_file(${CMAKE_CURRENT_SOURCE_DIR}/unit/staged_headers_tests.sh ${CMAKE_BINARY_DIR}/tests/unit/staged_headers_tests.sh COPYONLY) -add_test(NAME staged_headers_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/staged_headers_tests.sh "${CMAKE_BINARY_DIR}" "${CMAKE_SOURCE_DIR}" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) +add_test(NAME staged_headers_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/staged_headers_tests.sh "${CMAKE_BINARY_DIR}" "${CMAKE_SOURCE_DIR}" "${ENABLE_NATIVE_COMPILER}" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) set_property(TEST staged_headers_tests PROPERTY LABELS unit_tests) # sysio-pp (WABT post-pass) regression — guards the WSA-020 / SEC-10 diff --git a/tests/unit/abi_version_tests.sh b/tests/unit/abi_version_tests.sh index e5fbfe782..83d2e6c45 100755 --- a/tests/unit/abi_version_tests.sh +++ b/tests/unit/abi_version_tests.sh @@ -1,9 +1,10 @@ #!/bin/bash # Test ABI version and protobuf_types generation -# Usage: abi_version_tests.sh +# Usage: abi_version_tests.sh [source_dir] set -euo pipefail BUILD_DIR="$1" +SOURCE_DIR="${2:-}" CONTRACTS_DIR="${BUILD_DIR}/tests/unit/test_contracts" PASS=0 FAIL=0 @@ -146,7 +147,10 @@ check "1.10 keeps action_results (ABIMerger no longer parses the suffix)" \ # There is no ABI 0.x, and cdt-cpp reads a zero major as "option absent" -- accepting # one would reopen the driver/codegen divergence. Must be rejected, not coerced. -for bad in "not-a-version" "0.1" "1.2.3" "1x"; do +# 2.0 and 10.2 are rejected rather than accepted: abigen's to_json only serializes +# action_results when major == 1, so a higher major would be stamped onto an ABI missing +# the sections that version implies, and the merger would rank it above 1.2 regardless. +for bad in "not-a-version" "0.1" "1.2.3" "1x" "2.0" "10.2"; do if "$CDT_CPP" -abi-version "$bad" -abigen -contract verparse \ "-abigen_output=${WORK}/bad.abi" "${WORK}/verparse.cpp" \ -o "${WORK}/bad.wasm" > "${WORK}/bad.log" 2>&1; then @@ -160,6 +164,39 @@ for bad in "not-a-version" "0.1" "1.2.3" "1x"; do fi done +# --- protobuf version promotion --------------------------------------------------- +# +# A contract with protobuf files is stamped at 1.3. That promotion used to happen after +# the abigen plugin had already run, so `-abi-version 1.1` made the plugin suppress +# action_results under its own 1.2 gate, and codegen then stamped the incomplete output +# as 1.3 -- a version promising a section the descriptors no longer carried. The +# promotion now happens before the plugin is told the version, so a non-void protobuf +# action keeps its result entry. +echo "-- protobuf version promotion --" + +PB_SRC="${SOURCE_DIR:-}" +PB_GEN="${BUILD_DIR}/tests/unit/test_contracts" +MAGIC_ENUM_DIR="$(find "${BUILD_DIR}/vcpkg_installed" -maxdepth 3 -type d -name magic_enum 2>/dev/null | head -1)" + +if [ -z "$PB_SRC" ] || [ ! -f "${PB_SRC}/tests/unit/test_contracts/pb_tests.cpp" ] \ + || [ ! -d "${PB_GEN}/test" ] || [ -z "$MAGIC_ENUM_DIR" ]; then + echo " SKIP: protobuf inputs not locatable in this build tree" +else + if "$CDT_CPP" -abigen -abi-version 1.1 -contract pb_tests \ + -protobuf-dir "${PB_SRC}/tests/unit/test_contracts" -protobuf-files test.proto \ + -I "$PB_GEN" -I "${PB_SRC}/tests/unit/test_contracts" -I "$(dirname "$MAGIC_ENUM_DIR")" \ + "-abigen_output=${WORK}/pb11.abi" "${PB_SRC}/tests/unit/test_contracts/pb_tests.cpp" \ + -o "${WORK}/pb11.wasm" > "${WORK}/pb11.log" 2>&1; then + check "1.1 + protobuf is promoted to 1.3" \ + "${WORK}/pb11.abi" '"version": "sysio::abi/1.3"' + check "1.1 + protobuf keeps the non-void action's result" \ + "${WORK}/pb11.abi" '"name": "hiproto"' + else + fail "1.1 + protobuf builds" + sed 's/^/ /' "${WORK}/pb11.log" + fi +fi + echo "" echo "Results: ${PASS} passed, ${FAIL} failed" [ "$FAIL" -eq 0 ] \ No newline at end of file diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh new file mode 100755 index 000000000..7dbd8286d --- /dev/null +++ b/tests/unit/abidiff_tests.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Regression tests for cdt-abidiff's ABI version handling. +# +# get_version used to be `stod(ver.substr(ver.size() - 3)) * 10`, a fixed-width suffix +# read that returns 1 for "sysio::abi/1.10" (it sees ".10"). Both capability gates in +# diff() compared that against 11 and 12, so for any two-digit minor the variant and +# action-result diffs were silently skipped -- a real difference reported as none. The +# same suffix read also collapsed "eosio::abi/1.2" and "sysio::abi/1.2" to one number. +# +# cdt-abidiff now shares abi_version::parse_version_string and the supports_* predicates +# with the rest of the toolchain, so the gates compare (major, minor) components. +# +# Usage: abidiff_tests.sh +set -euo pipefail + +BIN_DIR="$1" +ABIDIFF="${BIN_DIR}/cdt-abidiff" +PASS=0 +FAIL=0 + +pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# Two ABIs identical but for one action_result entry. At any version that supports the +# section, the difference must be reported. +write_pair() { + local version="$1" + cat > "${WORK}/a.abi" < "${WORK}/b.abi" <&1 || true)" + if grep -q "geta\|getb" <<< "$out"; then + pass "$desc" + else + fail "$desc" + echo " expected the action_results difference to be reported; got:" + sed 's/^/ /' <<< "$out" + fi +} + +echo "=== cdt-abidiff Tests ===" + +check_reports_diff "sysio::abi/1.2" "1.2 reports an action_results difference" +check_reports_diff "sysio::abi/1.3" "1.3 reports an action_results difference" +# The regression: the suffix read scored this 1, below both gates, and reported nothing. +check_reports_diff "sysio::abi/1.10" "1.10 reports an action_results difference" + +# A version difference must be reported on its own, and must not be masked by two +# spellings collapsing to the same number. +# Both carry action_results: at any version at or above 1.2 cdt-abidiff will diff that +# section, and abigen always emits the array, so omitting it is not a valid document. +cat > "${WORK}/v1.abi" <<'EOF' +{ "version": "sysio::abi/1.2", "types": [], "structs": [], "actions": [], "tables": [], "ricardian_clauses": [], "variants": [], "action_results": [] } +EOF +cat > "${WORK}/v2.abi" <<'EOF' +{ "version": "sysio::abi/1.10", "types": [], "structs": [], "actions": [], "tables": [], "ricardian_clauses": [], "variants": [], "action_results": [] } +EOF +out="$("$ABIDIFF" "${WORK}/v1.abi" "${WORK}/v2.abi" 2>&1 || true)" +if grep -q "version" <<< "$out"; then + pass "1.2 vs 1.10 reports a version difference" +else + fail "1.2 vs 1.10 reports a version difference" + sed 's/^/ /' <<< "$out" +fi + +# Identical inputs must stay quiet. +out="$("$ABIDIFF" "${WORK}/v1.abi" "${WORK}/v1.abi" 2>&1 || true)" +if grep -q "version\|action_results" <<< "$out"; then + fail "identical ABIs report no difference" + sed 's/^/ /' <<< "$out" +else + pass "identical ABIs report no difference" +fi + +echo "" +echo "Results: ${PASS} passed, ${FAIL} failed" +[ "$FAIL" -eq 0 ] diff --git a/tests/unit/staged_headers_tests.sh b/tests/unit/staged_headers_tests.sh index 9559d30e9..17b474967 100755 --- a/tests/unit/staged_headers_tests.sh +++ b/tests/unit/staged_headers_tests.sh @@ -11,11 +11,12 @@ # pins the resulting invariant -- every staged header has a source counterpart -- so it # catches ANY future stale staging, not just the deletion that prompted it. # -# Usage: staged_headers_tests.sh +# Usage: staged_headers_tests.sh set -euo pipefail BUILD_DIR="$1" SOURCE_DIR="$2" +ENABLE_NATIVE="${3:-ON}" INCLUDE_DIR="${BUILD_DIR}/include" PASS=0 FAIL=0 @@ -77,6 +78,31 @@ else echo " stage_cdt_headers should have pruned these; see cmake/stage_headers.cmake" fi +# With native mode off, the native headers must not be staged at all. They are pruned +# unconditionally rather than inside the STAGE_NATIVE branch, because a build tree whose +# ENABLE_NATIVE_COMPILER flipped ON -> OFF would otherwise keep the previous build's copy +# -- and InstallCDT.cmake installs the whole include tree, so the OFF package would ship +# an API it was configured not to build. The counterpart check above cannot catch that: +# those files still have source counterparts, they simply should not be there. +if [ "$ENABLE_NATIVE" = "ON" ] || [ "$ENABLE_NATIVE" = "on" ] || [ "$ENABLE_NATIVE" = "1" ]; then + if [ -d "${INCLUDE_DIR}/sysio/native" ]; then + pass "native headers are staged (ENABLE_NATIVE_COMPILER=ON)" + else + fail "native headers are staged (ENABLE_NATIVE_COMPILER=ON)" + fi +else + leftovers=() + for d in "${INCLUDE_DIR}/sysio/native" "${INCLUDE_DIR}/sysiolib/native"; do + [ -d "$d" ] && leftovers+=("$d") + done + if [ "${#leftovers[@]}" -eq 0 ]; then + pass "native headers are absent (ENABLE_NATIVE_COMPILER=OFF)" + else + fail "native headers are absent (ENABLE_NATIVE_COMPILER=OFF)" + for d in "${leftovers[@]}"; do echo " still staged: $d"; done + fi +fi + echo "" echo "Results: ${PASS} passed, ${FAIL} failed" [ "$FAIL" -eq 0 ] diff --git a/tools/abidiff/cdt-abidiff.cpp.in b/tools/abidiff/cdt-abidiff.cpp.in index c4b8654d4..ab7790d0f 100644 --- a/tools/abidiff/cdt-abidiff.cpp.in +++ b/tools/abidiff/cdt-abidiff.cpp.in @@ -5,6 +5,9 @@ #include #include #include +#include + +#include #include @@ -46,9 +49,18 @@ class abidiff { } - int get_version(const ojson& abi) { - std::string ver = abi["version"].as(); - return (std::stod(ver.substr(ver.size()-3))*10); + /// The (major, minor) an ABI declares. + /// + /// The previous form -- stod over the string's last three characters -- read + /// "sysio::abi/1.10" as ".10" -> 1, so both capability gates below silently + /// skipped the variant and action-result diffs for a two-digit minor. It also + /// collapsed eosio::abi/1.2 and sysio::abi/1.2 to the same number. + std::pair get_version(const ojson& abi) { + int major_v = abi_version::default_major; + int minor_v = abi_version::default_minor; + if (abi.has_key("version")) + abi_version::parse_version_string(abi["version"].as(), major_v, minor_v); + return {major_v, minor_v}; } void diff_version() { @@ -263,9 +275,13 @@ class abidiff { diff_actions(); diff_tables(); diff_clauses(); - if ( get_version(abi_1) >= 11 && get_version(abi_2) >= 11 ) + const auto [v1_major, v1_minor] = get_version(abi_1); + const auto [v2_major, v2_minor] = get_version(abi_2); + if ( abi_version::supports_variants(v1_major, v1_minor) && + abi_version::supports_variants(v2_major, v2_minor) ) diff_variants(); - if ( get_version(abi_1) >= 12 && get_version(abi_2) >= 12 ) + if ( abi_version::supports_action_results(v1_major, v1_minor) && + abi_version::supports_action_results(v2_major, v2_minor) ) diff_action_results(); } }; diff --git a/tools/codegen/cdt-codegen.cpp b/tools/codegen/cdt-codegen.cpp index 28fbac09f..f80f6e353 100644 --- a/tools/codegen/cdt-codegen.cpp +++ b/tools/codegen/cdt-codegen.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -316,6 +317,19 @@ static void parse_args(int argc, const char** argv) { } } + // A contract with protobuf files ends up stamped at abi_version::protobuf_minor, so + // settle the effective version here -- before gen_actions hands it to the plugin and + // before the finalize pass stamps the merged ABI. The plugin gates its own sections on + // the version it is told, so promoting afterwards produced an ABI claiming 1.3 while + // missing the action_results that 1.2 already required, with the descriptors already + // written and the entries unrecoverable. Both passes run this, because cdt-ld forwards + // --protobuf-files and --abi-version into the finalize invocation. + if (protobuf_files.size() && abi_version_major == abi_version::default_major && + abi_version_minor < abi_version::protobuf_minor) { + abi_version_minor = abi_version::protobuf_minor; + abi_version_arg = abi_version::spelling(abi_version_major, abi_version_minor); + } + // The finalize pass does not compile anything (it only merges existing .desc files), // so it needs no --cxx options. if (!finalize_mode && cxx_arg.empty()) { @@ -701,13 +715,12 @@ int main(int argc, const char** argv) { abi["protobuf_types"] = ojson::parse(protobuf_types_json); - // The protobuf_types section is only understood from abi_version::protobuf_minor - // onwards, so a contract that emits one is bumped up to it. - if (abi_version_major == abi_version::default_major && - abi_version_minor < abi_version::protobuf_minor) { - abi_version_minor = abi_version::protobuf_minor; - abi["version"] = abi_version::version_string(abi_version_major, abi_version_minor); - } + // The promotion itself happened before gen_actions ran (see above), so the + // plugin already gated its sections on this version. All that is left is to + // stamp the merged document, whose version came from the descriptors. + assert(abi_version_minor >= abi_version::protobuf_minor || + abi_version_major != abi_version::default_major); + abi["version"] = abi_version::version_string(abi_version_major, abi_version_minor); } else if (referenced_pb_types.size()) { std::cerr << "protobuf types are used but no protobuf files are specified for contract " << contract_name << ", please use `contract_use_protobuf()` cmake function to specify the protobuf files it depends on\n"; diff --git a/tools/include/sysio/abi.hpp b/tools/include/sysio/abi.hpp index 9624483e2..4d8048a16 100644 --- a/tools/include/sysio/abi.hpp +++ b/tools/include/sysio/abi.hpp @@ -30,15 +30,37 @@ namespace abi_version { inline std::string default_spelling() { return spelling(default_major, default_minor); } + /// The highest ABI major this toolchain can emit. `to_json` only knows how to + /// serialize the 1.x shape, so accepting a higher major would stamp a version we + /// cannot honour -- a 2.0 ABI would silently lose every section gated below. + inline constexpr int max_supported_major = 1; + /// The minor from which the `protobuf_types` ABI section is understood. A /// contract that emits one is bumped from the baseline to here; a contract that /// does not stays at the baseline. inline constexpr int protobuf_minor = 3; - /// The minor from which `action_results` is part of the format. ABIMerger only - /// merges that section for a document at or above this version. + /// The minor from which `variants` is part of the format. + inline constexpr int variants_minor = 1; + + /// The minor from which `action_results` is part of the format. inline constexpr int action_results_minor = 2; + /// Does a version carry the `variants` section? + inline constexpr bool supports_variants(int major_v, int minor_v) { + return major_v == max_supported_major && minor_v >= variants_minor; + } + + /// Does a version carry the `action_results` section? + /// + /// One predicate for the whole toolchain: the abigen plugin decides with it + /// whether to emit the section, ABIMerger whether to merge it, and cdt-abidiff + /// whether to diff it. Three separate spellings of this rule is how a contract + /// ends up with a version stamp that promises a section its ABI does not carry. + inline constexpr bool supports_action_results(int major_v, int minor_v) { + return major_v == max_supported_major && minor_v >= action_results_minor; + } + /// The full "sysio::abi/." string stamped into a contract's ABI. inline std::string version_string(int major_v, int minor_v) { return "sysio::abi/" + spelling(major_v, minor_v); @@ -58,6 +80,11 @@ namespace abi_version { * `cdt-codegen --abi-version 0.1` honoured it -- reintroducing exactly the * divergence this namespace exists to remove. * + * A major above max_supported_major is rejected too. to_json only knows the 1.x + * shape and gates action_results on major == 1, so a 2.0 or 10.2 request would + * otherwise be accepted, compared as "newer than 1.2" by the merger, and then + * emitted without the very sections the higher version implies. + * * @param text the spelling to parse * @param major_out set to the major component on success; untouched on failure * @param minor_out set to the minor component on success (0 when omitted); @@ -90,7 +117,7 @@ namespace abi_version { } catch (const std::exception&) { return false; // out of int range } - if (major_v == 0) + if (major_v == 0 || major_v > max_supported_major) return false; major_out = major_v; diff --git a/tools/include/sysio/abimerge.hpp b/tools/include/sysio/abimerge.hpp index 7c1d96973..f9d8fdc21 100644 --- a/tools/include/sysio/abimerge.hpp +++ b/tools/include/sysio/abimerge.hpp @@ -44,6 +44,11 @@ class ABIMerger { ret["____comment"] = abi["____comment"]; else if (other.has_key("____comment")) ret["____comment"] = other["____comment"]; + // The emitted version is the newer of the two documents, so the capability + // gate below must consult THAT, not just the left-hand side. Gating on the + // left alone emitted e.g. 1.10 while dropping the action_results the newer + // side carried -- a version stamp promising a section the ABI lacks. + const std::pair merged_version = std::max(version_of(abi), version_of(other)); ret["version"] = merge_version(other); ret["types"] = merge_types(other); ret["structs"] = merge_structs(other); @@ -51,12 +56,9 @@ class ABIMerger { ret["tables"] = merge_tables(other); ret["ricardian_clauses"] = merge_clauses(other); ret["variants"] = merge_variants(other); - // action_results entered the format at abi_version::action_results_minor. // Compare parsed components: deriving them from the string's last three - // characters mis-read any two-digit minor ("sysio::abi/1.10" -> ".10") and - // any major >= 10 ("sysio::abi/10.2" -> "0.2"). - if (version_of(abi) >= std::pair{abi_version::default_major, - abi_version::action_results_minor}) { + // characters mis-read any two-digit minor ("sysio::abi/1.10" -> ".10"). + if (abi_version::supports_action_results(merged_version.first, merged_version.second)) { ret["action_results"] = merge_action_results(other); } { From ccdfe557255fa26631f9b598c97551d23d7ea4f5 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 31 Aug 2026 10:06:40 -0500 Subject: [PATCH 04/26] test: resolve magic_enum include from CMake, not a find(1) guess The protobuf promotion regression added in the previous commit located magic_enum by searching the vcpkg tree: find "${BUILD_DIR}/vcpkg_installed" -maxdepth 3 -type d -name magic_enum | head -1 That matches both `/include/magic_enum` and `/share/magic_enum`, and find(1) does not order its results. Locally the include copy came first; on the ubuntu24 runner the share copy did, so the hand-rolled compile was given an include path with no headers under it and failed with test.pb.hpp:6:10: fatal error: 'magic_enum/magic_enum.hpp' file not found The include directory now comes from CMake, which already resolves it, passed as a third argument from tests/CMakeLists.txt. The skip guard checks for the header itself rather than a directory name, so a build tree without it skips the case instead of failing on a path that merely looks right. Full suite 31/31; the skip path verified by passing a bogus and an empty dir. --- tests/CMakeLists.txt | 2 +- tests/unit/abi_version_tests.sh | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d54025ddb..8ddb8587b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -33,7 +33,7 @@ add_test(NAME version_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/version_tests set_property(TEST version_tests PROPERTY LABELS unit_tests) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/unit/abi_version_tests.sh ${CMAKE_BINARY_DIR}/tests/unit/abi_version_tests.sh COPYONLY) -add_test(NAME abi_version_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/abi_version_tests.sh "${CMAKE_BINARY_DIR}" "${CMAKE_SOURCE_DIR}" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) +add_test(NAME abi_version_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/abi_version_tests.sh "${CMAKE_BINARY_DIR}" "${CMAKE_SOURCE_DIR}" "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/include" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) set_property(TEST abi_version_tests PROPERTY LABELS unit_tests) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/unit/multidir_contract_tests.sh ${CMAKE_BINARY_DIR}/tests/unit/multidir_contract_tests.sh COPYONLY) diff --git a/tests/unit/abi_version_tests.sh b/tests/unit/abi_version_tests.sh index 83d2e6c45..3d569f277 100755 --- a/tests/unit/abi_version_tests.sh +++ b/tests/unit/abi_version_tests.sh @@ -1,10 +1,11 @@ #!/bin/bash # Test ABI version and protobuf_types generation -# Usage: abi_version_tests.sh [source_dir] +# Usage: abi_version_tests.sh [source_dir] [magic_enum_include_dir] set -euo pipefail BUILD_DIR="$1" SOURCE_DIR="${2:-}" +MAGIC_ENUM_INC="${3:-}" CONTRACTS_DIR="${BUILD_DIR}/tests/unit/test_contracts" PASS=0 FAIL=0 @@ -174,17 +175,21 @@ done # action keeps its result entry. echo "-- protobuf version promotion --" +# The magic_enum include directory is passed in from CMake, which already resolves it. +# Searching for it here found `vcpkg_installed//share/magic_enum` as readily as +# the include/ one -- find(1) does not order its matches -- and the share copy has no +# headers under it. PB_SRC="${SOURCE_DIR:-}" PB_GEN="${BUILD_DIR}/tests/unit/test_contracts" -MAGIC_ENUM_DIR="$(find "${BUILD_DIR}/vcpkg_installed" -maxdepth 3 -type d -name magic_enum 2>/dev/null | head -1)" if [ -z "$PB_SRC" ] || [ ! -f "${PB_SRC}/tests/unit/test_contracts/pb_tests.cpp" ] \ - || [ ! -d "${PB_GEN}/test" ] || [ -z "$MAGIC_ENUM_DIR" ]; then + || [ ! -d "${PB_GEN}/test" ] \ + || [ -z "$MAGIC_ENUM_INC" ] || [ ! -f "${MAGIC_ENUM_INC}/magic_enum/magic_enum.hpp" ]; then echo " SKIP: protobuf inputs not locatable in this build tree" else if "$CDT_CPP" -abigen -abi-version 1.1 -contract pb_tests \ -protobuf-dir "${PB_SRC}/tests/unit/test_contracts" -protobuf-files test.proto \ - -I "$PB_GEN" -I "${PB_SRC}/tests/unit/test_contracts" -I "$(dirname "$MAGIC_ENUM_DIR")" \ + -I "$PB_GEN" -I "${PB_SRC}/tests/unit/test_contracts" -I "$MAGIC_ENUM_INC" \ "-abigen_output=${WORK}/pb11.abi" "${PB_SRC}/tests/unit/test_contracts/pb_tests.cpp" \ -o "${WORK}/pb11.wasm" > "${WORK}/pb11.log" 2>&1; then check "1.1 + protobuf is promoted to 1.3" \ From 55e77c05b491db4551af610c2a8a49b9a1654c5f Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 31 Aug 2026 10:20:24 -0500 Subject: [PATCH 05/26] test: use ERE alternation in abidiff_tests Matches the repo's convention (tools/packaging/tests/verify-tgz.sh uses grep -E for alternation, plain grep -q for literals) and drops the reliance on `\|`, which is a GNU/BSD extension rather than POSIX BRE. Not a fix -- the macOS job ran the previous form successfully -- just not worth depending on. --- tests/unit/abidiff_tests.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index 7dbd8286d..6e49a45b8 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -52,7 +52,7 @@ check_reports_diff() { # cdt-abidiff exits non-zero when it finds differences, so tolerate that. local out out="$("$ABIDIFF" "${WORK}/a.abi" "${WORK}/b.abi" 2>&1 || true)" - if grep -q "geta\|getb" <<< "$out"; then + if grep -qE "geta|getb" <<< "$out"; then pass "$desc" else fail "$desc" @@ -88,7 +88,7 @@ fi # Identical inputs must stay quiet. out="$("$ABIDIFF" "${WORK}/v1.abi" "${WORK}/v1.abi" 2>&1 || true)" -if grep -q "version\|action_results" <<< "$out"; then +if grep -qE "version|action_results" <<< "$out"; then fail "identical ABIs report no difference" sed 's/^/ /' <<< "$out" else From 7a10f46233f1010dc8c2588e6d3a9053751fdc26 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 31 Aug 2026 12:20:11 -0500 Subject: [PATCH 06/26] =?UTF-8?q?chore:=20address=20third=20review=20?= =?UTF-8?q?=E2=80=94=20version=20parsing,=20mixed-version=20merge,=20OFF?= =?UTF-8?q?=20packaging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings. Four are regressions from the previous round, one of which was a test that never tested anything; two are pre-existing defects adjacent to code this PR touches. A failed version parse is no longer laundered into 1.2. parse_version_string leaves its outputs untouched on failure, and both call sites seeded them with the 1.2 defaults and ignored the result -- so once the previous round made parse() reject majors above 1, cdt-abidiff read sysio::abi/2.0 as 1.2 and reported no version difference against a 1.2 document. cdt-abidiff now diagnoses and exits; ABIMerger::version_of distinguishes a document with no version (which legitimately inherits the merger's, as an empty accumulator is seeded that way) from one whose version is present but unparsable, which throws. A missing versioned section reads as empty. Gating on the merged version was right, but add_object_to_array then indexed both documents unconditionally, so a valid 1.1 descriptor -- which omits action_results -- merged with a 1.10 one threw `Key 'action_results' not found`, failing the exact mixed-version case the gate was changed to support. Fixed in add_object_to_array so every versioned section is covered in both directions. find_action_results compared the matched entry against abi2[...].at(i) inside a loop over j. Reordered but equivalent results reported as changed, and a shorter right-hand side threw. Pre-existing -- it affects 1.2 and 1.3 -- but this PR routes 1.10 into that path. An ON->OFF rebuild no longer packages stale native archives. libnative*, and libsf.a are copied into lib/ by POST_BUILD commands that exist only while native mode is on; reconfiguring a reused tree to OFF drops the targets but not the files, and InstallCDT installs lib/ wholesale. In an existing tree those archives still hold the security-group symbols this PR removes, so the documented upgrade path could ship the very API being deleted. The staging script prunes them when native is off -- it already runs at build time and already prunes the header trees for the same reason -- and the libnative* install is now guarded on the option. stage_headers.cmake becomes stage_cdt_tree.cmake since it no longer stages only headers. The staging test takes $, matching the stage target's own canonicalization. Comparing the raw cache spelling meant a valid -DENABLE_NATIVE_COMPILER=TRUE staged both native trees while the test took its OFF branch. The protobuf regression assertion checked `"name": "hiproto"`, which is in the top-level actions array regardless -- so it passed on the regression it was written to catch. It now asserts result_type, which appears only under action_results. Each new assertion was confirmed to fail before the fix rather than only to pass after it: reverting the matcher and the version guard fails 2 of 7 abidiff cases, and reverting the section helper reproduces `Key 'action_results' not found` with no ABI emitted. The mixed-version merge is now driven end to end through `cdt-codegen --finalize`, the real ABIMerger entry point. 31/31 ctest. --- cmake/InstallCDT.cmake | 11 ++++-- cmake/package.cmake | 2 +- ...age_headers.cmake => stage_cdt_tree.cmake} | 19 +++++++-- libraries/CMakeLists.txt | 10 ++--- libraries/native/CMakeLists.txt | 2 +- libraries/sysiolib/CMakeLists.txt | 2 +- tests/CMakeLists.txt | 2 +- tests/unit/abi_version_tests.sh | 39 ++++++++++++++++++- tests/unit/abidiff_tests.sh | 35 +++++++++++++++++ tests/unit/staged_headers_tests.sh | 28 ++++++++----- tools/abidiff/cdt-abidiff.cpp.in | 34 +++++++++++----- tools/include/sysio/abimerge.hpp | 36 +++++++++++++---- 12 files changed, 178 insertions(+), 42 deletions(-) rename cmake/{stage_headers.cmake => stage_cdt_tree.cmake} (74%) diff --git a/cmake/InstallCDT.cmake b/cmake/InstallCDT.cmake index 36716064c..e82102fa8 100644 --- a/cmake/InstallCDT.cmake +++ b/cmake/InstallCDT.cmake @@ -46,9 +46,14 @@ macro( cdt_libraries_install) install(DIRECTORY ${CMAKE_BINARY_DIR}/lib/ DESTINATION lib COMPONENT base PATTERN "libnative*" EXCLUDE PATTERN "cmake" EXCLUDE) - install(DIRECTORY ${CMAKE_BINARY_DIR}/lib/ DESTINATION lib COMPONENT dev - FILES_MATCHING PATTERN "libnative*" - PATTERN "cmake" EXCLUDE) + # Guarded on the option, not merely on what happens to be sitting in lib/: a tree + # reconfigured from native ON to OFF can still hold archives from the previous build. + # stage_cdt_tree prunes those, and this makes packaging one impossible regardless. + if(ENABLE_NATIVE_COMPILER) + install(DIRECTORY ${CMAKE_BINARY_DIR}/lib/ DESTINATION lib COMPONENT dev + FILES_MATCHING PATTERN "libnative*" + PATTERN "cmake" EXCLUDE) + endif() install(DIRECTORY ${CMAKE_BINARY_DIR}/include/ DESTINATION include COMPONENT base) endmacro( cdt_libraries_install ) diff --git a/cmake/package.cmake b/cmake/package.cmake index 858d5572f..e45d4bd34 100644 --- a/cmake/package.cmake +++ b/cmake/package.cmake @@ -179,7 +179,7 @@ set(CPACK_WIRE_PUBLIC_ENTRY_POINTS "${CDT_PUBLIC_ENTRY_POINTS}") # cmake/cpack-project-config.cmake), so this no longer renames anything -- it is # a plain alias, kept because CI and the docs invoke it by name. # Depends on CDTWasmLibraries because header staging (and its pruning) happens in that -# nested build -- see cmake/stage_headers.cmake. The generated `package` and `install` +# nested build -- see cmake/stage_cdt_tree.cmake. The generated `package` and `install` # targets are ordered after `all` and so pick it up for free, but this convenience # target is standalone: without the dependency, `cmake --build . --target package-tgz` # on a reused tree could run CPack over a stale staged header a prior build deleted. diff --git a/cmake/stage_headers.cmake b/cmake/stage_cdt_tree.cmake similarity index 74% rename from cmake/stage_headers.cmake rename to cmake/stage_cdt_tree.cmake index 1e525bee5..c1490d4c9 100644 --- a/cmake/stage_headers.cmake +++ b/cmake/stage_cdt_tree.cmake @@ -1,6 +1,7 @@ -# Stage the CDT-owned header trees into /include, pruning first. +# Stage the CDT-owned parts of the build tree -- headers into /include and the +# native archives in /lib -- pruning first. # -# Run in script mode (`cmake -P`) from the `stage_cdt_headers` build target, not at +# Run in script mode (`cmake -P`) from the `stage_cdt_tree` build target, not at # configure time. Configure-time `file(COPY)` is additive: it never removes a staged # copy whose source has been deleted, so a removed header stayed in /include # forever -- shipped by install/CPack, and visible to native consumers whose compiled @@ -24,7 +25,7 @@ foreach(var STAGE_SOURCE_DIR STAGE_BINARY_DIR) if(NOT DEFINED ${var}) - message(FATAL_ERROR "stage_headers.cmake: ${var} is required") + message(FATAL_ERROR "stage_cdt_tree.cmake: ${var} is required") endif() endforeach() @@ -44,6 +45,18 @@ file(COPY "${STAGE_SOURCE_DIR}/sysiolib" DESTINATION "${STAGE_BINARY_DIR}/include" ${header_patterns}) +# The native archives are copied into lib/ by POST_BUILD commands that exist only while +# ENABLE_NATIVE_COMPILER is on. Reconfiguring a reused tree to OFF removes those targets but +# not the files they already copied, and InstallCDT.cmake installs lib/ wholesale -- so an OFF +# build packaged archives its own configuration never produced, still carrying whatever symbols +# the last ON build put in them. +if(NOT STAGE_NATIVE) + file(GLOB stale_native "${STAGE_BINARY_DIR}/lib/libnative*" "${STAGE_BINARY_DIR}/lib/libsf.a") + if(stale_native) + file(REMOVE ${stale_native}) + endif() +endif() + if(STAGE_NATIVE) # native -> include/sysio/native file(COPY "${STAGE_SOURCE_DIR}/native" diff --git a/libraries/CMakeLists.txt b/libraries/CMakeLists.txt index 95397ff88..a5627d6cf 100644 --- a/libraries/CMakeLists.txt +++ b/libraries/CMakeLists.txt @@ -20,15 +20,15 @@ set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_EXTENSIONS ON) # Stage the CDT-owned headers into ${BASE_BINARY_DIR}/include at BUILD time, pruning -# any whose source has been deleted. See cmake/stage_headers.cmake for why this cannot +# any whose source has been deleted. See cmake/stage_cdt_tree.cmake for why this cannot # be a configure-time file(COPY). -add_custom_target(stage_cdt_headers ALL +add_custom_target(stage_cdt_tree ALL COMMAND ${CMAKE_COMMAND} -DSTAGE_SOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR} -DSTAGE_BINARY_DIR=${BASE_BINARY_DIR} -DSTAGE_NATIVE=$ - -P ${CMAKE_CURRENT_SOURCE_DIR}/../cmake/stage_headers.cmake - COMMENT "Staging CDT headers into ${BASE_BINARY_DIR}/include" + -P ${CMAKE_CURRENT_SOURCE_DIR}/../cmake/stage_cdt_tree.cmake + COMMENT "Staging CDT build tree into ${BASE_BINARY_DIR}/include" VERBATIM) add_subdirectory(libc) @@ -43,7 +43,7 @@ endif() # Anything compiled against the staged tree must see the pruned copy, not a leftover. foreach(staged_consumer sysio native native_sysio) if(TARGET ${staged_consumer}) - add_dependencies(${staged_consumer} stage_cdt_headers) + add_dependencies(${staged_consumer} stage_cdt_tree) endif() endforeach() diff --git a/libraries/native/CMakeLists.txt b/libraries/native/CMakeLists.txt index 9788f7209..e9e135e15 100644 --- a/libraries/native/CMakeLists.txt +++ b/libraries/native/CMakeLists.txt @@ -364,4 +364,4 @@ add_custom_command( TARGET native POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy $< add_custom_command( TARGET sf POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy $ ${BASE_BINARY_DIR}/lib ) -# Header staging lives in the stage_cdt_headers target (cmake/stage_headers.cmake). +# Header staging lives in the stage_cdt_tree target (cmake/stage_cdt_tree.cmake). diff --git a/libraries/sysiolib/CMakeLists.txt b/libraries/sysiolib/CMakeLists.txt index d0a5944c0..70c037903 100644 --- a/libraries/sysiolib/CMakeLists.txt +++ b/libraries/sysiolib/CMakeLists.txt @@ -48,4 +48,4 @@ if (ENABLE_NATIVE_COMPILER) add_custom_command( TARGET native_sysio POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy $ ${BASE_BINARY_DIR}/lib ) endif() -# Header staging lives in the stage_cdt_headers target (cmake/stage_headers.cmake). +# Header staging lives in the stage_cdt_tree target (cmake/stage_cdt_tree.cmake). diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8ddb8587b..18fe310a0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -49,7 +49,7 @@ set_property(TEST abidiff_tests PROPERTY LABELS unit_tests) # Staged-header hygiene — every header under /include must still exist in # libraries/. Catches a stale copy left behind when a source header is deleted. configure_file(${CMAKE_CURRENT_SOURCE_DIR}/unit/staged_headers_tests.sh ${CMAKE_BINARY_DIR}/tests/unit/staged_headers_tests.sh COPYONLY) -add_test(NAME staged_headers_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/staged_headers_tests.sh "${CMAKE_BINARY_DIR}" "${CMAKE_SOURCE_DIR}" "${ENABLE_NATIVE_COMPILER}" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) +add_test(NAME staged_headers_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/staged_headers_tests.sh "${CMAKE_BINARY_DIR}" "${CMAKE_SOURCE_DIR}" "$" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) set_property(TEST staged_headers_tests PROPERTY LABELS unit_tests) # sysio-pp (WABT post-pass) regression — guards the WSA-020 / SEC-10 diff --git a/tests/unit/abi_version_tests.sh b/tests/unit/abi_version_tests.sh index 3d569f277..3a2643439 100755 --- a/tests/unit/abi_version_tests.sh +++ b/tests/unit/abi_version_tests.sh @@ -194,14 +194,51 @@ else -o "${WORK}/pb11.wasm" > "${WORK}/pb11.log" 2>&1; then check "1.1 + protobuf is promoted to 1.3" \ "${WORK}/pb11.abi" '"version": "sysio::abi/1.3"' + # Must key on result_type: "name": "hiproto" also appears in the top-level actions + # array, so asserting the name passed even when the old late-promotion path had + # suppressed action_results entirely -- i.e. it passed on the regression it exists + # to catch. result_type appears only under action_results. check "1.1 + protobuf keeps the non-void action's result" \ - "${WORK}/pb11.abi" '"name": "hiproto"' + "${WORK}/pb11.abi" '"result_type": "protobuf::test.ActResult"' else fail "1.1 + protobuf builds" sed 's/^/ /' "${WORK}/pb11.log" fi fi +# --- mixed-version descriptor merge ----------------------------------------------- +# +# Sections enter the format at a version, so a valid 1.1 descriptor omits action_results. +# Once the capability gate consults the MERGED version, such a descriptor merged with a +# newer one reaches merge_action_results, and indexing the older side unconditionally threw +# `Key 'action_results' not found` -- failing the very mixed-version case the gate enables. +# Driven through `cdt-codegen --finalize`, which is the real ABIMerger entry point. +echo "-- mixed-version descriptor merge --" + +CDT_CODEGEN="${BUILD_DIR}/bin/cdt-codegen" +MERGE_COMMON='"types":[],"tables":[],"ricardian_clauses":[],"variants":[],"abi_extensions":[],"pb_types":[],"wasm_actions":[],"wasm_entries":[],"wasm_notifies":[]' + +cat > "${WORK}/old.desc" < "${WORK}/new.desc" < "${WORK}/mix.log" 2>&1; then + check "1.1 + 1.10 merge emits the newer version" \ + "${WORK}/mix.abi" '"version": "sysio::abi/1.10"' + # result_type, not name: "actb" is in the actions array too, so asserting the name + # would pass even with action_results dropped entirely. + check "1.1 + 1.10 merge retains the newer side's action_result" \ + "${WORK}/mix.abi" '"result_type": "uint64"' +else + fail "1.1 + 1.10 descriptors merge" + sed 's/^/ /' "${WORK}/mix.log" +fi + echo "" echo "Results: ${PASS} passed, ${FAIL} failed" [ "$FAIL" -eq 0 ] \ No newline at end of file diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index 6e49a45b8..5dfc5f285 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -95,6 +95,41 @@ else pass "identical ABIs report no difference" fi +# Reordered but equivalent action_results must NOT report a difference. find_action_results +# compared the matched entry against abi2[...].at(i) instead of .at(j), so once a name matched +# at a different index the result_type comparison read the wrong entry and reported all four +# sides as changed. Pre-existing, but this PR routes 1.10 through that path. +cat > "${WORK}/r1.abi" <<'EOF' +{ "version": "sysio::abi/1.10", "types": [], "structs": [], "actions": [], "tables": [], "ricardian_clauses": [], "variants": [], + "action_results": [ { "name": "geta", "result_type": "uint64" }, { "name": "getb", "result_type": "uint32" } ] } +EOF +cat > "${WORK}/r2.abi" <<'EOF' +{ "version": "sysio::abi/1.10", "types": [], "structs": [], "actions": [], "tables": [], "ricardian_clauses": [], "variants": [], + "action_results": [ { "name": "getb", "result_type": "uint32" }, { "name": "geta", "result_type": "uint64" } ] } +EOF +out="$("$ABIDIFF" "${WORK}/r1.abi" "${WORK}/r2.abi" 2>&1 || true)" +if grep -qE "geta|getb" <<< "$out"; then + fail "reordered equivalent action_results report no difference" + sed 's/^/ /' <<< "$out" +else + pass "reordered equivalent action_results report no difference" +fi + +# An unsupported or unparsable version must be refused, not silently read as the 1.2 default. +# parse() rejects majors above 1, so seeding the outputs with 1.2 and ignoring the result made +# a 2.0 document compare equal to a 1.2 one. +cat > "${WORK}/v20.abi" <<'EOF' +{ "version": "sysio::abi/2.0", "types": [], "structs": [], "actions": [], "tables": [], "ricardian_clauses": [], "variants": [], "action_results": [] } +EOF +if "$ABIDIFF" "${WORK}/v20.abi" "${WORK}/v1.abi" > "${WORK}/v20.log" 2>&1; then + fail "an unsupported ABI version is refused" +elif grep -q "unsupported ABI version" "${WORK}/v20.log"; then + pass "an unsupported ABI version is refused with a diagnostic" +else + fail "unsupported ABI version refused, but without the expected diagnostic" + sed 's/^/ /' "${WORK}/v20.log" +fi + echo "" echo "Results: ${PASS} passed, ${FAIL} failed" [ "$FAIL" -eq 0 ] diff --git a/tests/unit/staged_headers_tests.sh b/tests/unit/staged_headers_tests.sh index 17b474967..f10914120 100755 --- a/tests/unit/staged_headers_tests.sh +++ b/tests/unit/staged_headers_tests.sh @@ -7,16 +7,20 @@ # the rebuilt library. Reusing a build tree across such a deletion never recovered, # because the ExternalProject's configure step is stamped and does not re-run. # -# stage_cdt_headers (cmake/stage_headers.cmake) now prunes before it copies. This test +# stage_cdt_tree (cmake/stage_cdt_tree.cmake) now prunes before it copies. This test # pins the resulting invariant -- every staged header has a source counterpart -- so it # catches ANY future stale staging, not just the deletion that prompted it. # -# Usage: staged_headers_tests.sh +# Usage: staged_headers_tests.sh +# +# The caller passes $, the same canonicalization the stage +# target uses. Comparing the raw cache spelling here meant a valid setting like +# -DENABLE_NATIVE_COMPILER=TRUE staged the native trees while this script took its OFF branch. set -euo pipefail BUILD_DIR="$1" SOURCE_DIR="$2" -ENABLE_NATIVE="${3:-ON}" +NATIVE_ENABLED="${3:-1}" INCLUDE_DIR="${BUILD_DIR}/include" PASS=0 FAIL=0 @@ -75,7 +79,7 @@ else fail "every staged header has a source counterpart" echo " ${#stale[@]} staged header(s) no longer exist in libraries/:" for f in "${stale[@]}"; do echo " include/${f}"; done - echo " stage_cdt_headers should have pruned these; see cmake/stage_headers.cmake" + echo " stage_cdt_tree should have pruned these; see cmake/stage_cdt_tree.cmake" fi # With native mode off, the native headers must not be staged at all. They are pruned @@ -84,21 +88,27 @@ fi # -- and InstallCDT.cmake installs the whole include tree, so the OFF package would ship # an API it was configured not to build. The counterpart check above cannot catch that: # those files still have source counterparts, they simply should not be there. -if [ "$ENABLE_NATIVE" = "ON" ] || [ "$ENABLE_NATIVE" = "on" ] || [ "$ENABLE_NATIVE" = "1" ]; then +if [ "$NATIVE_ENABLED" = "1" ]; then if [ -d "${INCLUDE_DIR}/sysio/native" ]; then - pass "native headers are staged (ENABLE_NATIVE_COMPILER=ON)" + pass "native headers are staged (native enabled)" else - fail "native headers are staged (ENABLE_NATIVE_COMPILER=ON)" + fail "native headers are staged (native enabled)" fi else leftovers=() for d in "${INCLUDE_DIR}/sysio/native" "${INCLUDE_DIR}/sysiolib/native"; do [ -d "$d" ] && leftovers+=("$d") done + # The native archives are copied into lib/ by POST_BUILD commands that only exist while + # native mode is on. They survive a reconfigure to OFF, and InstallCDT installs lib/ + # wholesale, so a stale one gets packaged carrying the previous build's symbols. + for f in "${BUILD_DIR}"/lib/libnative* "${BUILD_DIR}/lib/libsf.a"; do + [ -e "$f" ] && leftovers+=("$f") + done if [ "${#leftovers[@]}" -eq 0 ]; then - pass "native headers are absent (ENABLE_NATIVE_COMPILER=OFF)" + pass "native headers and archives are absent (native disabled)" else - fail "native headers are absent (ENABLE_NATIVE_COMPILER=OFF)" + fail "native headers and archives are absent (native disabled)" for d in "${leftovers[@]}"; do echo " still staged: $d"; done fi fi diff --git a/tools/abidiff/cdt-abidiff.cpp.in b/tools/abidiff/cdt-abidiff.cpp.in index ab7790d0f..859a41026 100644 --- a/tools/abidiff/cdt-abidiff.cpp.in +++ b/tools/abidiff/cdt-abidiff.cpp.in @@ -55,16 +55,29 @@ class abidiff { /// "sysio::abi/1.10" as ".10" -> 1, so both capability gates below silently /// skipped the variant and action-result diffs for a two-digit minor. It also /// collapsed eosio::abi/1.2 and sysio::abi/1.2 to the same number. - std::pair get_version(const ojson& abi) { - int major_v = abi_version::default_major; - int minor_v = abi_version::default_minor; - if (abi.has_key("version")) - abi_version::parse_version_string(abi["version"].as(), major_v, minor_v); + /// A failed parse is refused, not defaulted. Seeding the outputs with 1.2 and + /// ignoring the result meant an unparsable or unsupported version -- including + /// `sysio::abi/2.0`, which the shared parser rejects -- silently compared equal to + /// a 1.2 document. Every gate below keys off this value, so a version we cannot + /// read is a reason to stop rather than to guess. + std::pair get_version(const ojson& abi, const std::string& which) { + if (!abi.has_key("version")) { + std::cerr << "cdt-abidiff: " << which << " has no \"version\" field\n"; + exit(1); + } + const auto text = abi["version"].as(); + int major_v = 0; + int minor_v = 0; + if (!abi_version::parse_version_string(text, major_v, minor_v)) { + std::cerr << "cdt-abidiff: " << which << " declares an unsupported ABI version '" + << text << "'\n"; + exit(1); + } return {major_v, minor_v}; } void diff_version() { - if (get_version(abi_1) != get_version(abi_2)) { + if (get_version(abi_1, "file1") != get_version(abi_2, "file2")) { std::cout << "< version\n\t"; std::cout << abi_1["version"] << "\n"; std::cout << "> version\n\t"; @@ -223,7 +236,10 @@ class abidiff { bool found = false; for ( int j=0; j < abi2["action_results"].size(); j++ ) { if (abi1["action_results"].at(i)["name"] == abi2["action_results"].at(j)["name"]) { - if (abi1["action_results"].at(i)["result_type"] != abi2["action_results"].at(i)["result_type"]) + // .at(j), not .at(i): j is the entry whose name just matched. Comparing + // against .at(i) reported reordered-but-equivalent results as changed, and + // threw outright when abi2 held fewer entries than abi1. + if (abi1["action_results"].at(i)["result_type"] != abi2["action_results"].at(j)["result_type"]) break; found = true; } @@ -275,8 +291,8 @@ class abidiff { diff_actions(); diff_tables(); diff_clauses(); - const auto [v1_major, v1_minor] = get_version(abi_1); - const auto [v2_major, v2_minor] = get_version(abi_2); + const auto [v1_major, v1_minor] = get_version(abi_1, "file1"); + const auto [v2_major, v2_minor] = get_version(abi_2, "file2"); if ( abi_version::supports_variants(v1_major, v1_minor) && abi_version::supports_variants(v2_major, v2_minor) ) diff_variants(); diff --git a/tools/include/sysio/abimerge.hpp b/tools/include/sysio/abimerge.hpp index f9d8fdc21..7f3408c01 100644 --- a/tools/include/sysio/abimerge.hpp +++ b/tools/include/sysio/abimerge.hpp @@ -69,13 +69,21 @@ class ABIMerger { return ret; } private: - /// The (major, minor) a document declares, falling back to this merger's own - /// version when it carries none or an unparsable one. + /// The (major, minor) a document declares. + /// + /// A document with no `version` at all legitimately inherits this merger's version -- + /// that is how an empty accumulator is seeded. A document whose `version` is present + /// but unparsable is malformed, and is rejected rather than quietly treated as the + /// default: every capability gate below keys off this value. std::pair version_of(const ojson& doc) const { - int major_v = major; - int minor_v = minor; - if (doc.has_key("version")) - abi_version::parse_version_string(doc["version"].as(), major_v, minor_v); + if (!doc.has_key("version")) + return {major, minor}; + + const auto text = doc["version"].as(); + int major_v = 0; + int minor_v = 0; + if (!abi_version::parse_version_string(text, major_v, minor_v)) + throw std::runtime_error("Error, ABI declares an unsupported version : " + text); return {major_v, minor_v}; } @@ -160,12 +168,24 @@ class ABIMerger { a["values"] == b["values"]; } + /// A section a document does not carry reads as empty. + /// + /// Sections enter the format at a version (action_results at 1.2), so a valid older + /// document simply omits them. Once the capability gate consults the *merged* version, + /// a 1.1 accumulator merged with a 1.10 descriptor reaches this code and indexing the + /// older side unconditionally threw `Key 'action_results' not found` -- failing exactly + /// the mixed-version merge the gate was changed to support. + static const ojson& section(const ojson& doc, const std::string& type) { + static const ojson empty = ojson::array(); + return doc.has_key(type) ? doc[type] : empty; + } + template void add_object_to_array(ojson& ret, ojson a, ojson b, std::string type, std::string id, F&& is_same_func) { - for (auto obj_a : a[type].array_range()) { + for (auto obj_a : section(a, type).array_range()) { ret.push_back(obj_a); } - for (auto obj_b : b[type].array_range()) { + for (auto obj_b : section(b, type).array_range()) { bool should_skip = false; for (size_t i = 0; i < ret.size(); ++i) { if (ret[i][id] == obj_b[id]) { From dd9ccdff09aa51a1db19c00f1421444b0e25fb71 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 31 Aug 2026 15:55:58 -0500 Subject: [PATCH 07/26] =?UTF-8?q?chore:=20address=20fourth=20review=20?= =?UTF-8?q?=E2=80=94=20required=20sections,=20variant=20matching,=20prune?= =?UTF-8?q?=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings. Two are fallout from the previous round, one is the sibling of a matcher fixed there that I did not check at the time. Required descriptor sections are mandatory again. The section() helper added last round backs the generic add_object_to_array, which handles eight sections, so it made structs, types, actions, tables and ricardian_clauses optional along with the version-gated ones. A truncated descriptor missing actions merged to "actions": [] where it had previously thrown, silently dropping contract interface content. section() now takes a section_kind, and only action_results, variants and enums -- the sections that entered the format at a version -- may be absent. The mixed-version merge test only exercised one order. cdt-codegen sorts the descriptor paths, so filenames rather than argument order decide which document becomes the accumulator, and new.desc/old.desc always processed the newer first. The fixture now names them explicitly and runs both directions. find_variants had the same defect as find_action_results: it broke out of the element loop on a type mismatch and then set found unconditionally, so a same-named variant counted as unchanged however its types differed, and with no length comparison at(k) threw on a shorter right-hand side. Pre-existing, but this PR routes 1.10 documents into that matcher -- and I should have checked the sibling when fixing the first one. It now compares the whole list, length first. The ON->OFF prune had no coverage that runs. The staging test only described the mode the build was configured in, ENABLE_NATIVE_COMPILER defaults ON and neither workflow overrides it, so the OFF assertions never executed; and a clean OFF build would not prove the prune either, having no stale outputs to remove. The test now seeds an isolated tree the way a previous ON build leaves one, invokes stage_cdt_tree.cmake with STAGE_NATIVE=0 directly, and asserts the archives and header trees are gone while an unrelated archive survives. That is mode-independent and always runs. Each new assertion was confirmed to fail before its fix: reverting the variant matcher fails both variant cases (no difference reported, then a throw on the shorter side), and reverting the section change accepts the truncated descriptor. 31/31 ctest. --- tests/unit/abi_version_tests.sh | 49 +++++++++++++++++++++++------- tests/unit/abidiff_tests.sh | 44 +++++++++++++++++++++++++++ tests/unit/staged_headers_tests.sh | 46 ++++++++++++++++++++++++++++ tools/abidiff/cdt-abidiff.cpp.in | 17 +++++++++-- tools/include/sysio/abimerge.hpp | 35 ++++++++++++--------- 5 files changed, 163 insertions(+), 28 deletions(-) diff --git a/tests/unit/abi_version_tests.sh b/tests/unit/abi_version_tests.sh index 3a2643439..4cdba6589 100755 --- a/tests/unit/abi_version_tests.sh +++ b/tests/unit/abi_version_tests.sh @@ -24,6 +24,7 @@ check() { } fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } +pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } check_absent() { local desc="$1" file="$2" pattern="$3" @@ -225,18 +226,44 @@ cat > "${WORK}/new.desc" < "${WORK}/mix.log" 2>&1; then - check "1.1 + 1.10 merge emits the newer version" \ - "${WORK}/mix.abi" '"version": "sysio::abi/1.10"' - # result_type, not name: "actb" is in the actions array too, so asserting the name - # would pass even with action_results dropped entirely. - check "1.1 + 1.10 merge retains the newer side's action_result" \ - "${WORK}/mix.abi" '"result_type": "uint64"' +# Both merge orders. cdt-codegen sorts the descriptor paths, so the filenames -- not the +# --desc-file argument order -- decide which document becomes the accumulator. Naming them +# a_/b_ makes each case explicit instead of accidental. +merge_case() { + local label="$1" first="$2" second="$3" + local dir="${WORK}/${label}" + mkdir -p "$dir" + cp "${WORK}/${first}.desc" "${dir}/a_first.desc" + cp "${WORK}/${second}.desc" "${dir}/b_second.desc" + if "$CDT_CODEGEN" --finalize --contract mix --output-dir "$dir" \ + --abi-output "${dir}/mix.abi" \ + --desc-file "${dir}/a_first.desc" --desc-file "${dir}/b_second.desc" \ + > "${dir}/mix.log" 2>&1; then + check "${label}: emits the newer version" "${dir}/mix.abi" '"version": "sysio::abi/1.10"' + # result_type, not name: "actb" is in the actions array too, so asserting the name + # would pass even with action_results dropped entirely. + check "${label}: retains the newer side's action_result" "${dir}/mix.abi" '"result_type": "uint64"' + else + fail "${label}: descriptors merge" + sed 's/^/ /' "${dir}/mix.log" + fi +} + +merge_case "older-first" old new +merge_case "newer-first" new old + +# A descriptor missing a REQUIRED section is truncated, not merely older, and must be +# rejected rather than merged as empty -- otherwise contract interface content is dropped +# silently. Only version-gated sections (action_results, variants, enums) may be absent. +cat > "${WORK}/truncated.desc" < "${WORK}/trunc.log" 2>&1; then + fail "a descriptor missing a required section is rejected" else - fail "1.1 + 1.10 descriptors merge" - sed 's/^/ /' "${WORK}/mix.log" + pass "a descriptor missing a required section is rejected" fi echo "" diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index 5dfc5f285..684f16e01 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -130,6 +130,50 @@ else sed 's/^/ /' "${WORK}/v20.log" fi +# Variants at 1.10. find_variants broke out of the element loop on a type mismatch and then +# set found unconditionally, so a same-named variant counted as unchanged however its types +# differed; with no length check, at(k) threw on a shorter right-hand side. This PR is what +# routes 1.10 into that matcher. +mkvariant() { # file, types-json + cat > "$1" <&1 || true)" +if grep -q "variant" <<< "$out"; then + pass "1.10 variants differing by type report a difference" +else + fail "1.10 variants differing by type report a difference" + sed 's/^/ /' <<< "$out" +fi + +mkvariant "${WORK}/vlong.abi" '["uint64", "string"]' +mkvariant "${WORK}/vshort.abi" '["uint64"]' +out="$("$ABIDIFF" "${WORK}/vlong.abi" "${WORK}/vshort.abi" 2>&1 || true)" +if grep -qiE "invalid array subscript|terminate|Aborted" <<< "$out"; then + fail "1.10 variants of differing length are compared without throwing" + sed 's/^/ /' <<< "$out" +elif grep -q "variant" <<< "$out"; then + pass "1.10 variants of differing length report a difference" +else + fail "1.10 variants of differing length report a difference" + sed 's/^/ /' <<< "$out" +fi + +mkvariant "${WORK}/vsame1.abi" '["uint64", "string"]' +mkvariant "${WORK}/vsame2.abi" '["uint64", "string"]' +out="$("$ABIDIFF" "${WORK}/vsame1.abi" "${WORK}/vsame2.abi" 2>&1 || true)" +if grep -q "variant" <<< "$out"; then + fail "identical 1.10 variants report no difference" + sed 's/^/ /' <<< "$out" +else + pass "identical 1.10 variants report no difference" +fi + echo "" echo "Results: ${PASS} passed, ${FAIL} failed" [ "$FAIL" -eq 0 ] diff --git a/tests/unit/staged_headers_tests.sh b/tests/unit/staged_headers_tests.sh index f10914120..9ef616c37 100755 --- a/tests/unit/staged_headers_tests.sh +++ b/tests/unit/staged_headers_tests.sh @@ -113,6 +113,52 @@ else fi fi +# --- ON -> OFF prune, in an isolated tree ------------------------------------------ +# +# The checks above only describe the mode this build was configured in, and +# ENABLE_NATIVE_COMPILER defaults ON with neither workflow overriding it -- so the OFF +# assertions never ran in CI. A clean OFF build would not prove the prune either: it has no +# stale native outputs to remove. So drive the staging script directly against a scratch tree +# seeded the way a previous ON build leaves one, which is mode-independent and always runs. +echo "-- ON -> OFF prune (isolated tree) --" + +if ! command -v cmake > /dev/null 2>&1; then + echo " SKIP: cmake not on PATH" +else + SCRATCH="$(mktemp -d)" + trap 'rm -rf "$SCRATCH"' EXIT + mkdir -p "${SCRATCH}/lib" "${SCRATCH}/include/sysio/native" "${SCRATCH}/include/sysiolib/native" + for f in libnative.a libnative_sysio.a libsf.a libc.a; do echo stale > "${SCRATCH}/lib/${f}"; done + : > "${SCRATCH}/include/sysio/native/sentinel.hpp" + : > "${SCRATCH}/include/sysiolib/native/sentinel.hpp" + + if cmake -DSTAGE_SOURCE_DIR="${SOURCE_DIR}/libraries" -DSTAGE_BINARY_DIR="${SCRATCH}" \ + -DSTAGE_NATIVE=0 -P "${SOURCE_DIR}/cmake/stage_cdt_tree.cmake" \ + > "${SCRATCH}/stage.log" 2>&1; then + leftovers=() + for f in "${SCRATCH}/lib/libnative.a" "${SCRATCH}/lib/libnative_sysio.a" "${SCRATCH}/lib/libsf.a" \ + "${SCRATCH}/include/sysio/native" "${SCRATCH}/include/sysiolib/native"; do + [ -e "$f" ] && leftovers+=("$f") + done + if [ "${#leftovers[@]}" -eq 0 ]; then + pass "STAGE_NATIVE=0 prunes stale native archives and header trees" + else + fail "STAGE_NATIVE=0 prunes stale native archives and header trees" + for f in "${leftovers[@]}"; do echo " survived: $f"; done + fi + + # An unrelated archive must be left alone -- the prune is targeted, not a wipe. + if [ -e "${SCRATCH}/lib/libc.a" ]; then + pass "the prune leaves unrelated archives alone" + else + fail "the prune leaves unrelated archives alone" + fi + else + fail "stage_cdt_tree.cmake runs with STAGE_NATIVE=0" + sed 's/^/ /' "${SCRATCH}/stage.log" + fi +fi + echo "" echo "Results: ${PASS} passed, ${FAIL} failed" [ "$FAIL" -eq 0 ] diff --git a/tools/abidiff/cdt-abidiff.cpp.in b/tools/abidiff/cdt-abidiff.cpp.in index 859a41026..8f4376c9b 100644 --- a/tools/abidiff/cdt-abidiff.cpp.in +++ b/tools/abidiff/cdt-abidiff.cpp.in @@ -214,9 +214,20 @@ class abidiff { bool found = false; for ( int j=0; j < abi2["variants"].size(); j++ ) { if (abi1["variants"].at(i)["name"] == abi2["variants"].at(j)["name"]) { - for (int k=0; k < abi1["variants"].at(i)["types"].size(); k++) - if (abi1["variants"].at(i)["types"].at(k) != abi2["variants"].at(j)["types"].at(k)) - break; + // Compare the whole type list, length first. The previous form broke out of + // the element loop on a mismatch and then set found unconditionally, so a + // same-named variant always counted as unchanged however its types differed; + // and with no length check, at(k) threw on a shorter right-hand side. + const auto& t1 = abi1["variants"].at(i)["types"]; + const auto& t2 = abi2["variants"].at(j)["types"]; + if (t1.size() != t2.size()) + break; + bool same_types = true; + for (size_t k = 0; k < t1.size(); ++k) { + if (t1.at(k) != t2.at(k)) { same_types = false; break; } + } + if (!same_types) + break; found = true; } diff --git a/tools/include/sysio/abimerge.hpp b/tools/include/sysio/abimerge.hpp index 7f3408c01..98631cc3e 100644 --- a/tools/include/sysio/abimerge.hpp +++ b/tools/include/sysio/abimerge.hpp @@ -168,24 +168,31 @@ class ABIMerger { a["values"] == b["values"]; } - /// A section a document does not carry reads as empty. + /// Whether a descriptor may legitimately omit a section. /// - /// Sections enter the format at a version (action_results at 1.2), so a valid older - /// document simply omits them. Once the capability gate consults the *merged* version, - /// a 1.1 accumulator merged with a 1.10 descriptor reaches this code and indexing the - /// older side unconditionally threw `Key 'action_results' not found` -- failing exactly - /// the mixed-version merge the gate was changed to support. - static const ojson& section(const ojson& doc, const std::string& type) { + /// Only sections that entered the format at a version are optional -- a valid 1.1 + /// document has no `action_results`, a 1.0 one has no `variants`. The baseline arrays + /// are always emitted by abigen, so a descriptor missing one is truncated or corrupt + /// and must not be quietly merged as empty: that would drop contract interface content + /// silently. An earlier revision made every section optional and did exactly that. + enum class section_kind { required, version_gated }; + + static const ojson& section(const ojson& doc, const std::string& type, section_kind kind) { static const ojson empty = ojson::array(); - return doc.has_key(type) ? doc[type] : empty; + if (doc.has_key(type)) + return doc[type]; + if (kind == section_kind::version_gated) + return empty; + throw std::runtime_error("Error, ABI is missing required section : " + type); } template - void add_object_to_array(ojson& ret, ojson a, ojson b, std::string type, std::string id, F&& is_same_func) { - for (auto obj_a : section(a, type).array_range()) { + void add_object_to_array(ojson& ret, ojson a, ojson b, std::string type, std::string id, + F&& is_same_func, section_kind kind = section_kind::required) { + for (auto obj_a : section(a, type, kind).array_range()) { ret.push_back(obj_a); } - for (auto obj_b : section(b, type).array_range()) { + for (auto obj_b : section(b, type, kind).array_range()) { bool should_skip = false; for (size_t i = 0; i < ret.size(); ++i) { if (ret[i][id] == obj_b[id]) { @@ -241,7 +248,7 @@ class ABIMerger { ojson merge_variants(ojson b) { ojson vars = ojson::array(); - add_object_to_array(vars, abi, b, "variants", "name", variant_is_same); + add_object_to_array(vars, abi, b, "variants", "name", variant_is_same, section_kind::version_gated); return vars; } @@ -265,7 +272,7 @@ class ABIMerger { ojson merge_action_results(ojson b) { ojson res = ojson::array(); - add_object_to_array(res, abi, b, "action_results", "name", action_result_is_same); + add_object_to_array(res, abi, b, "action_results", "name", action_result_is_same, section_kind::version_gated); return res; } @@ -274,7 +281,7 @@ class ABIMerger { if (abi.has_key("enums") || b.has_key("enums")) { if (!abi.has_key("enums")) abi["enums"] = ojson::array(); if (!b.has_key("enums")) b["enums"] = ojson::array(); - add_object_to_array(enums, abi, b, "enums", "name", enum_is_same); + add_object_to_array(enums, abi, b, "enums", "name", enum_is_same, section_kind::version_gated); } return enums; } From 8d9468284a7f6cb2886e80fc2c47cabf07e746f5 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 1 Sep 2026 06:50:42 -0500 Subject: [PATCH 08/26] =?UTF-8?q?chore:=20address=20fifth=20review=20?= =?UTF-8?q?=E2=80=94=20versionless=20descriptors,=20gated=20sections,=20te?= =?UTF-8?q?st=20rigor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings, all correct, all fallout from the previous round. A versionless document is malformed, not a default. version_of() fell back to the merger's own version when a document had no `version` key, justified as the accumulator case -- but the constructor seeds an empty accumulator WITH a version and merge() always stamps ret["version"], so that fallback was unreachable for valid input and only ever fired on a malformed external descriptor, stamping the emission default onto input the base implementation rejected. It throws. version_gated meant "optional at every version", which is too weak. abigen emits `variants` in every document and `action_results` in every document whose version supports it, so a 1.10 descriptor missing either is truncated rather than old, and was being normalized to an empty array. Sections now carry the version they entered at -- variants at 1.1, action_results at 1.2 -- and absence is permitted only below it. `enums` is emitted only when non-empty, so it stays optional everywhere and carries no threshold. The both-orders merge test did not test what it claimed. Neither invocation passed --abi-version, so the accumulator started at the 1.2 default and the constructor gave it an empty action_results; the only absent key was ever on the right, and a right-side-only fallback would have passed both cases. Seeding at 1.1 makes the first merge emit an intermediate carrying no action_results, so the second meets the missing section on the LEFT. Verified: with the left-side fallback removed, older-first fails and newer-first still passes -- exactly the asymmetry the old fixture could not see. abidiff tests no longer mask process failures. cdt-abidiff exits 0 after reporting ordinary differences -- confirmed empirically; the comment claiming otherwise was wrong -- so `|| true` was not merely redundant but harmful: a crash whose output lacked the searched keyword counted as a pass. Valid-input invocations now go through a helper that fails the case outright on a non-zero exit, and only the unsupported-version case expects one. 31/31 ctest. --- .gitignore | 4 ++ tests/unit/abi_version_tests.sh | 12 ++++-- tests/unit/abidiff_tests.sh | 53 ++++++++++++++++++-------- tools/include/sysio/abimerge.hpp | 65 +++++++++++++++++++++----------- 4 files changed, 94 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index d761dcdab..a73afd95a 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,7 @@ tmp/ # prequel local review state (operational artifacts, never committed) .prequel/ + +# Core dumps (deliberate-crash test runs leave these behind) +core.* +vgcore.* diff --git a/tests/unit/abi_version_tests.sh b/tests/unit/abi_version_tests.sh index 4cdba6589..d1ce93b5f 100755 --- a/tests/unit/abi_version_tests.sh +++ b/tests/unit/abi_version_tests.sh @@ -229,13 +229,19 @@ EOF # Both merge orders. cdt-codegen sorts the descriptor paths, so the filenames -- not the # --desc-file argument order -- decide which document becomes the accumulator. Naming them # a_/b_ makes each case explicit instead of accidental. +# The seed version matters as much as the order. Without --abi-version the accumulator starts +# at the 1.2 default and the constructor gives it an empty action_results, so the only absent +# key is ever on the right -- a one-sided fallback would pass. Seeding older-first at 1.1 +# makes the first merge emit an intermediate with no action_results at all, so the second +# merge meets the missing section on the LEFT, which is the case that actually regressed. merge_case() { - local label="$1" first="$2" second="$3" + local label="$1" first="$2" second="$3" seed="$4" local dir="${WORK}/${label}" mkdir -p "$dir" cp "${WORK}/${first}.desc" "${dir}/a_first.desc" cp "${WORK}/${second}.desc" "${dir}/b_second.desc" if "$CDT_CODEGEN" --finalize --contract mix --output-dir "$dir" \ + --abi-version "$seed" \ --abi-output "${dir}/mix.abi" \ --desc-file "${dir}/a_first.desc" --desc-file "${dir}/b_second.desc" \ > "${dir}/mix.log" 2>&1; then @@ -249,8 +255,8 @@ merge_case() { fi } -merge_case "older-first" old new -merge_case "newer-first" new old +merge_case "older-first" old new 1.1 +merge_case "newer-first" new old 1.1 # A descriptor missing a REQUIRED section is truncated, not merely older, and must be # rejected rather than merged as empty -- otherwise contract interface content is dropped diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index 684f16e01..97c681b64 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -19,6 +19,21 @@ PASS=0 FAIL=0 pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } + +# Valid input: cdt-abidiff must exit 0 whether or not it reports differences. A non-zero +# status is a crash or a rejection, never "found a difference", so it propagates to the +# caller instead of being folded into the output text with `|| true`. +run_abidiff() { "$ABIDIFF" "$@" 2>&1; } + +# Capture output, failing the named case outright if the process did not exit 0. +# Sets `out`; returns non-zero when the case has already been failed. +capture() { + local desc="$1"; shift + out="$(run_abidiff "$@")" && return 0 + fail "${desc} (cdt-abidiff exited non-zero)" + sed 's/^/ /' <<< "$out" + return 1 +} fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } WORK="$(mktemp -d)" @@ -49,9 +64,16 @@ EOF check_reports_diff() { local version="$1" desc="$2" write_pair "$version" - # cdt-abidiff exits non-zero when it finds differences, so tolerate that. - local out - out="$("$ABIDIFF" "${WORK}/a.abi" "${WORK}/b.abi" 2>&1 || true)" + # cdt-abidiff exits 0 after reporting ordinary differences, so a non-zero status here is + # a real failure -- a crash or a bad-input rejection -- and must not be swallowed. Only + # the unsupported-version case below expects non-zero. + local out rc + out="$("$ABIDIFF" "${WORK}/a.abi" "${WORK}/b.abi" 2>&1)"; rc=$? + if [ "$rc" -ne 0 ]; then + fail "$desc (cdt-abidiff exited $rc)" + sed 's/^/ /' <<< "$out" + return + fi if grep -qE "geta|getb" <<< "$out"; then pass "$desc" else @@ -78,8 +100,8 @@ EOF cat > "${WORK}/v2.abi" <<'EOF' { "version": "sysio::abi/1.10", "types": [], "structs": [], "actions": [], "tables": [], "ricardian_clauses": [], "variants": [], "action_results": [] } EOF -out="$("$ABIDIFF" "${WORK}/v1.abi" "${WORK}/v2.abi" 2>&1 || true)" -if grep -q "version" <<< "$out"; then +if capture "1.2 vs 1.10 reports a version difference" "${WORK}/v1.abi" "${WORK}/v2.abi" && + grep -q "version" <<< "$out"; then pass "1.2 vs 1.10 reports a version difference" else fail "1.2 vs 1.10 reports a version difference" @@ -87,8 +109,9 @@ else fi # Identical inputs must stay quiet. -out="$("$ABIDIFF" "${WORK}/v1.abi" "${WORK}/v1.abi" 2>&1 || true)" -if grep -qE "version|action_results" <<< "$out"; then +if ! capture "identical ABIs report no difference" "${WORK}/v1.abi" "${WORK}/v1.abi"; then + : +elif grep -qE "version|action_results" <<< "$out"; then fail "identical ABIs report no difference" sed 's/^/ /' <<< "$out" else @@ -143,8 +166,8 @@ EOF mkvariant "${WORK}/va.abi" '["uint64"]' mkvariant "${WORK}/vb.abi" '["string"]' -out="$("$ABIDIFF" "${WORK}/va.abi" "${WORK}/vb.abi" 2>&1 || true)" -if grep -q "variant" <<< "$out"; then +if capture "1.10 variants differing by type report a difference" "${WORK}/va.abi" "${WORK}/vb.abi" && + grep -q "variant" <<< "$out"; then pass "1.10 variants differing by type report a difference" else fail "1.10 variants differing by type report a difference" @@ -153,10 +176,9 @@ fi mkvariant "${WORK}/vlong.abi" '["uint64", "string"]' mkvariant "${WORK}/vshort.abi" '["uint64"]' -out="$("$ABIDIFF" "${WORK}/vlong.abi" "${WORK}/vshort.abi" 2>&1 || true)" -if grep -qiE "invalid array subscript|terminate|Aborted" <<< "$out"; then - fail "1.10 variants of differing length are compared without throwing" - sed 's/^/ /' <<< "$out" +if ! capture "1.10 variants of differing length report a difference" \ + "${WORK}/vlong.abi" "${WORK}/vshort.abi"; then + : elif grep -q "variant" <<< "$out"; then pass "1.10 variants of differing length report a difference" else @@ -166,8 +188,9 @@ fi mkvariant "${WORK}/vsame1.abi" '["uint64", "string"]' mkvariant "${WORK}/vsame2.abi" '["uint64", "string"]' -out="$("$ABIDIFF" "${WORK}/vsame1.abi" "${WORK}/vsame2.abi" 2>&1 || true)" -if grep -q "variant" <<< "$out"; then +if ! capture "identical 1.10 variants report no difference" "${WORK}/vsame1.abi" "${WORK}/vsame2.abi"; then + : +elif grep -q "variant" <<< "$out"; then fail "identical 1.10 variants report no difference" sed 's/^/ /' <<< "$out" else diff --git a/tools/include/sysio/abimerge.hpp b/tools/include/sysio/abimerge.hpp index 98631cc3e..de1a038fa 100644 --- a/tools/include/sysio/abimerge.hpp +++ b/tools/include/sysio/abimerge.hpp @@ -6,6 +6,7 @@ #include #include "abi.hpp" +#include #include #include #include @@ -71,13 +72,15 @@ class ABIMerger { private: /// The (major, minor) a document declares. /// - /// A document with no `version` at all legitimately inherits this merger's version -- - /// that is how an empty accumulator is seeded. A document whose `version` is present - /// but unparsable is malformed, and is rejected rather than quietly treated as the - /// default: every capability gate below keys off this value. + /// Every document reaching here carries a version: the constructor seeds an empty + /// accumulator with one, merge() always stamps ret["version"], and abigen emits it in + /// every descriptor. So a missing key is a malformed external document rather than the + /// accumulator case, and defaulting it silently stamped the emission default onto input + /// the base implementation rejected. An unparsable version is malformed for the same + /// reason -- every capability gate below keys off this value. std::pair version_of(const ojson& doc) const { if (!doc.has_key("version")) - return {major, minor}; + throw std::runtime_error("Error, ABI is missing its version"); const auto text = doc["version"].as(); int major_v = 0; @@ -168,31 +171,42 @@ class ABIMerger { a["values"] == b["values"]; } - /// Whether a descriptor may legitimately omit a section. + /// The version at which a section entered the format, or nullopt for one that is never + /// mandatory. /// - /// Only sections that entered the format at a version are optional -- a valid 1.1 - /// document has no `action_results`, a 1.0 one has no `variants`. The baseline arrays - /// are always emitted by abigen, so a descriptor missing one is truncated or corrupt - /// and must not be quietly merged as empty: that would drop contract interface content - /// silently. An earlier revision made every section optional and did exactly that. - enum class section_kind { required, version_gated }; - - static const ojson& section(const ojson& doc, const std::string& type, section_kind kind) { + /// Absence is only legitimate below that version: abigen emits `variants` in every + /// document and `action_results` in every document whose version supports it, so a 1.10 + /// descriptor missing either is truncated, not merely old. Treating them as optional at + /// every version -- as an earlier revision did -- silently dropped contract interface + /// content. `enums` is emitted only when non-empty, so it is genuinely optional + /// everywhere and carries no threshold. + using section_since = std::optional>; + + static constexpr std::pair baseline_section{0, 0}; + static const section_since variants_since; + static const section_since action_results_since; + static const section_since never_mandatory; + + static const ojson& section(const ojson& doc, const std::string& type, + const section_since& since, std::pair doc_version) { static const ojson empty = ojson::array(); if (doc.has_key(type)) return doc[type]; - if (kind == section_kind::version_gated) + if (!since || doc_version < *since) return empty; - throw std::runtime_error("Error, ABI is missing required section : " + type); + throw std::runtime_error("Error, ABI at " + + abi_version::version_string(doc_version.first, doc_version.second) + + " is missing section : " + type); } template void add_object_to_array(ojson& ret, ojson a, ojson b, std::string type, std::string id, - F&& is_same_func, section_kind kind = section_kind::required) { - for (auto obj_a : section(a, type, kind).array_range()) { + F&& is_same_func, + const section_since& since = section_since{baseline_section}) { + for (auto obj_a : section(a, type, since, version_of(a)).array_range()) { ret.push_back(obj_a); } - for (auto obj_b : section(b, type, kind).array_range()) { + for (auto obj_b : section(b, type, since, version_of(b)).array_range()) { bool should_skip = false; for (size_t i = 0; i < ret.size(); ++i) { if (ret[i][id] == obj_b[id]) { @@ -248,7 +262,7 @@ class ABIMerger { ojson merge_variants(ojson b) { ojson vars = ojson::array(); - add_object_to_array(vars, abi, b, "variants", "name", variant_is_same, section_kind::version_gated); + add_object_to_array(vars, abi, b, "variants", "name", variant_is_same, variants_since); return vars; } @@ -272,7 +286,7 @@ class ABIMerger { ojson merge_action_results(ojson b) { ojson res = ojson::array(); - add_object_to_array(res, abi, b, "action_results", "name", action_result_is_same, section_kind::version_gated); + add_object_to_array(res, abi, b, "action_results", "name", action_result_is_same, action_results_since); return res; } @@ -281,7 +295,7 @@ class ABIMerger { if (abi.has_key("enums") || b.has_key("enums")) { if (!abi.has_key("enums")) abi["enums"] = ojson::array(); if (!b.has_key("enums")) b["enums"] = ojson::array(); - add_object_to_array(enums, abi, b, "enums", "name", enum_is_same, section_kind::version_gated); + add_object_to_array(enums, abi, b, "enums", "name", enum_is_same, never_mandatory); } return enums; } @@ -290,4 +304,11 @@ class ABIMerger { int major = abi_version::default_major; ///< version this merger was constructed for int minor = abi_version::default_minor; }; + +inline const ABIMerger::section_since ABIMerger::variants_since{ + std::pair{abi_version::default_major, abi_version::variants_minor}}; +inline const ABIMerger::section_since ABIMerger::action_results_since{ + std::pair{abi_version::default_major, abi_version::action_results_minor}}; +inline const ABIMerger::section_since ABIMerger::never_mandatory{}; + #pragma GCC diagnostic pop From 9f9473b4b1c8a0982e7e620f10c917a886717ded Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 1 Sep 2026 09:34:48 -0500 Subject: [PATCH 09/26] =?UTF-8?q?chore:=20address=20sixth=20review=20?= =?UTF-8?q?=E2=80=94=20crash=20artifacts,=20test=20rigor,=20dead=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-scope the crash-artifact ignore patterns. The committed core dump is gone from history (that commit was amended and force-pushed), but the follow-up rule I added was `core.*` unanchored, which would also have matched tracked headers such as libraries/boost/include/boost/hana/core.hpp -- silently ignoring a real source file the next time one appeared. Now /core, /core.* and /vgcore.*, which still catch a dump in the working directory. Route the last two abidiff calls through capture(). One kept a `|| true`, the exact false-pass pattern the previous commit set out to remove. The other used a manual `out=$(...); rc=$?` sequence that could never report: under `set -e` a failing command substitution in an assignment aborts the script before $? is read, so the diagnostic branch was dead code. Give each new validation rule its own regression. The truncated-descriptor case only proved a baseline section is required, so reverting either the versionless rejection or the per-version thresholds left the suite green. Five cases now cover both rules and the boundaries between them -- versionless rejected, 1.0 may omit variants and action_results, 1.1 requires variants but may omit action_results, 1.2 requires action_results -- each asserting the expected diagnostic. Confirmed to have teeth: reverting both rules fails four of the five. Drop the merger's stored major/minor. The missing-version fallback was their only reader; now that a versionless document is an error, the constructor parameters alone suffice to seed an empty accumulator. 31/31 ctest. --- .gitignore | 9 ++++-- tests/unit/abi_version_tests.sh | 50 +++++++++++++++++++++++++++++++- tests/unit/abidiff_tests.sh | 19 +++++------- tools/include/sysio/abimerge.hpp | 10 +++---- 4 files changed, 66 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index a73afd95a..d81e241c4 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,9 @@ tmp/ # prequel local review state (operational artifacts, never committed) .prequel/ -# Core dumps (deliberate-crash test runs leave these behind) -core.* -vgcore.* +# Core dumps (a deliberate-crash test run leaves these in the working directory). +# Root-scoped: an unanchored core.* would also match tracked headers such as +# libraries/boost/include/boost/hana/core.hpp. +/core +/core.* +/vgcore.* diff --git a/tests/unit/abi_version_tests.sh b/tests/unit/abi_version_tests.sh index d1ce93b5f..6af32920f 100755 --- a/tests/unit/abi_version_tests.sh +++ b/tests/unit/abi_version_tests.sh @@ -260,7 +260,9 @@ merge_case "newer-first" new old 1.1 # A descriptor missing a REQUIRED section is truncated, not merely older, and must be # rejected rather than merged as empty -- otherwise contract interface content is dropped -# silently. Only version-gated sections (action_results, variants, enums) may be absent. +# silently. Baseline sections are required at every version; `variants` and `action_results` +# are required from the version that introduced them (1.1 and 1.2) and may be absent only +# below it; `enums` alone is optional everywhere, being emitted only when non-empty. cat > "${WORK}/truncated.desc" < "${dir}/d.desc" + if "$CDT_CODEGEN" --finalize --contract vcase --output-dir "$dir" \ + --abi-version "$seed" --abi-output "${dir}/out.abi" \ + --desc-file "${dir}/d.desc" > "${dir}/log" 2>&1; then + if [ "$expect" = "accept" ]; then pass "$label"; else fail "$label (accepted)"; fi + else + if [ "$expect" = "reject" ]; then + if [ -z "$diag" ] || grep -q "$diag" "${dir}/log"; then + pass "$label" + else + fail "$label (rejected, but not with the expected diagnostic)" + sed 's/^/ /' "${dir}/log" + fi + else + fail "$label (rejected)" + sed 's/^/ /' "${dir}/log" + fi + fi +} + +BASE='"structs":[],"types":[],"actions":[],"tables":[],"ricardian_clauses":[],"abi_extensions":[],"pb_types":[],"wasm_actions":[],"wasm_entries":[],"wasm_notifies":[]' + +validation_case "a versionless descriptor is rejected" reject 1.2 \ + "{${BASE},\"variants\":[],\"action_results\":[]}" "missing its version" + +validation_case "1.0 may omit variants and action_results" accept 1.0 \ + "{\"version\":\"sysio::abi/1.0\",${BASE}}" + +validation_case "1.1 requires variants" reject 1.1 \ + "{\"version\":\"sysio::abi/1.1\",${BASE},\"action_results\":[]}" "missing section : variants" + +validation_case "1.1 may omit action_results" accept 1.1 \ + "{\"version\":\"sysio::abi/1.1\",${BASE},\"variants\":[]}" + +validation_case "1.2 requires action_results" reject 1.2 \ + "{\"version\":\"sysio::abi/1.2\",${BASE},\"variants\":[]}" "missing section : action_results" + echo "" echo "Results: ${PASS} passed, ${FAIL} failed" [ "$FAIL" -eq 0 ] \ No newline at end of file diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index 97c681b64..e0289a134 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -64,16 +64,9 @@ EOF check_reports_diff() { local version="$1" desc="$2" write_pair "$version" - # cdt-abidiff exits 0 after reporting ordinary differences, so a non-zero status here is - # a real failure -- a crash or a bad-input rejection -- and must not be swallowed. Only - # the unsupported-version case below expects non-zero. - local out rc - out="$("$ABIDIFF" "${WORK}/a.abi" "${WORK}/b.abi" 2>&1)"; rc=$? - if [ "$rc" -ne 0 ]; then - fail "$desc (cdt-abidiff exited $rc)" - sed 's/^/ /' <<< "$out" - return - fi + # Via capture(), not a manual rc=$? -- under `set -e` a failing command substitution in an + # assignment aborts the script before $? can be read, so that branch was unreachable. + capture "$desc" "${WORK}/a.abi" "${WORK}/b.abi" || return if grep -qE "geta|getb" <<< "$out"; then pass "$desc" else @@ -130,8 +123,10 @@ cat > "${WORK}/r2.abi" <<'EOF' { "version": "sysio::abi/1.10", "types": [], "structs": [], "actions": [], "tables": [], "ricardian_clauses": [], "variants": [], "action_results": [ { "name": "getb", "result_type": "uint32" }, { "name": "geta", "result_type": "uint64" } ] } EOF -out="$("$ABIDIFF" "${WORK}/r1.abi" "${WORK}/r2.abi" 2>&1 || true)" -if grep -qE "geta|getb" <<< "$out"; then +if ! capture "reordered equivalent action_results report no difference" \ + "${WORK}/r1.abi" "${WORK}/r2.abi"; then + : +elif grep -qE "geta|getb" <<< "$out"; then fail "reordered equivalent action_results report no difference" sed 's/^/ /' <<< "$out" else diff --git a/tools/include/sysio/abimerge.hpp b/tools/include/sysio/abimerge.hpp index de1a038fa..c1551b8d9 100644 --- a/tools/include/sysio/abimerge.hpp +++ b/tools/include/sysio/abimerge.hpp @@ -16,10 +16,10 @@ using jsoncons::ojson; class ABIMerger { public: - ABIMerger(ojson a, int version_major, int version_minor) - : abi(a) - , major(version_major) - , minor(version_minor) { + /// version_major/version_minor seed an empty accumulator and are not retained: every + /// document that reaches version_of() declares its own version, and one that does not + /// is rejected rather than falling back to the merger's. + ABIMerger(ojson a, int version_major, int version_minor) : abi(a) { if (abi.empty()) { abi["version"] = abi_version::version_string(version_major, version_minor); abi["types"] = ojson::array(); @@ -301,8 +301,6 @@ class ABIMerger { } ojson abi; - int major = abi_version::default_major; ///< version this merger was constructed for - int minor = abi_version::default_minor; }; inline const ABIMerger::section_since ABIMerger::variants_since{ From b5394ffab415203cb9cd98b122084ec9e4624025 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 1 Sep 2026 10:03:55 -0500 Subject: [PATCH 10/26] chore: narrow core-dump ignore patterns to real dump shapes Matches the patterns on the multi_index branch. A root-anchored /core.* still hides a root-level core.cpp or core.hpp; restricting to the shapes core_pattern (core.%e.%p) actually produces keeps those visible while still catching core.., core. and valgrind's vgcore.. --- .gitignore | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index d81e241c4..70fdc9ce1 100644 --- a/.gitignore +++ b/.gitignore @@ -72,9 +72,10 @@ tmp/ # prequel local review state (operational artifacts, never committed) .prequel/ -# Core dumps (a deliberate-crash test run leaves these in the working directory). -# Root-scoped: an unanchored core.* would also match tracked headers such as -# libraries/boost/include/boost/hana/core.hpp. +# Core dumps. Restricted to the shapes the kernel actually writes here -- core_pattern is +# core.%e.%p -- and root-anchored, so neither a tracked header such as +# libraries/boost/include/boost/hana/core.hpp nor a future core.cpp/core.hpp is hidden. /core -/core.* -/vgcore.* +/core.[0-9]* +/core.*.[0-9]* +/vgcore.[0-9]* From b82df2f7c7f738ac439b7cdec2cd4d15ffa5bb38 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 1 Sep 2026 12:08:41 -0500 Subject: [PATCH 11/26] chore: fix the remaining ABI matchers and finish the staging rework Seventh review round. Three matcher defects survived the earlier rounds -- each is the same shape as one already fixed in the same file, which is why they kept being missed. cdt-abidiff: - find_structs kept a success flag across its field loop and broke out of that loop on a mismatch without clearing it, so only a difference in the FIRST field was ever reported. It also seeded the flag false and set it only inside the loop, so two byte-identical zero-field structs -- which every parameterless action generates -- compared as different, making the tool emit false positives on essentially every real contract. - find_tables compared only name and type. index_type, key_names, key_types and table_id could all change and it reported nothing. That is the metadata a contract upgrade turns on, and table_id is where the row physically lives. This also corrects my own diagnosis in an earlier thread: I said the version parse was why abidiff missed table-metadata changes. It was not -- the matcher never compared those fields. - find_variants now shares one arrays_equal helper with the other two rather than open-coding its own list comparison. ABIMerger: - variant_is_same asked only whether every type in one variant appeared somewhere in the other, with no length check, so ["uint64"] and ["uint64","string"] compared equal. Depending on sorted .desc filename order, merging them either dropped the `string` alternative silently or failed the build with "v already defined". Both orders now conflict. - struct_is_same matched fields by set membership plus size, so the same struct declared with reordered fields merged as identical and the alphabetically-first descriptor won. ABI field order is serialization order, so that was a wire-layout change decided by a filename. - table_is_same never compared key_types at all; it now does, with the same empty-array tolerance already documented for key_names. - The section thresholds keyed off default_major, the emission default, where they mean the major of the FORMAT. Equal today; a bump would have moved every threshold silently. - variants was emitted unconditionally while action_results was gated, so a 1.0 document merged at 1.0 produced a 1.0 ABI carrying variants -- contradicting the variants_since rule declared a few lines below it. - and were reached only through jsoncons. - Dead: ABIMerger::action_is_almost_same and abidiff::get_base_type. cdt-codegen: the protobuf branch stamped the CLI version unconditionally, downgrading a merged document whose descriptors declared something newer -- reachable through the fallback scan that picks up .desc files from earlier compiles run with a different -abi-version. Takes the newer of the two now. The assert beside it was tautological (parse() bounds the major to exactly max_supported_major) and compiled away under the default Release build. Removal: one stale sysio_wasm_import survived, set_kv_parameters_packed. Comparing all 103 CDT declarations against the chain's 116 intrinsics leaves exactly that one with no counterpart; wire-sysio mentions it only in a CHANGELOG. Same failure mode as the security-group four this PR removes -- declares cleanly, imports, fails at deploy. Staging: the invariant covered two of six trees. libc, libcxx, boost/preprocessor and bluegrass were still configure-time file(COPY), so deleting a header from the cdt-musl or cdt-libcxx submodule left the staged copy shipping forever -- the exact bug this rework exists to fix. All six are pruned and recopied together now. The consumer fencing was a hand-maintained three-name allowlist around a step that REMOVE_RECURSEs a directory, missing sysio_malloc, sysio_dsm, sysio_cmem, c, c++, rt, sf and the native_* variants; it enumerates the directory tree instead. Packaging: the base install excluded libnative* but not libsf.a, which is also native-only, so an ENABLE_NATIVE_COMPILER=OFF package still relied entirely on the prune. Tests: 15 new assertions across abidiff_tests.sh and abi_version_tests.sh, each verified to fail against the pre-fix code. Reverting find_structs and find_tables fails 6 of them; reverting variant_is_same and struct_is_same fails 3, and reproduces the order-dependence exactly -- the variant case merged in one descriptor order and refused in the other. ctest 31/31. The .gitignore hunk is dropped from this PR: it was identical to #113's and conflicted with it. It lands once, on #113. --- .gitignore | 14 +-- CLAUDE.md | 2 +- cmake/InstallCDT.cmake | 1 + cmake/stage_cdt_tree.cmake | 30 +++++- imports/cdt.imports.in | 1 - libraries/CMakeLists.txt | 31 +++++-- libraries/libc++/CMakeLists.txt | 1 - libraries/libc/CMakeLists.txt | 3 - libraries/sysiolib/capi/sysio/privileged.h | 10 -- tests/unit/abi_version_tests.sh | 60 ++++++++++++ tests/unit/abidiff_tests.sh | 81 +++++++++++++++- tools/abidiff/cdt-abidiff.cpp.in | 103 ++++++++++++--------- tools/codegen/cdt-codegen.cpp | 21 ++++- tools/include/sysio/abimerge.hpp | 85 ++++++++++------- 14 files changed, 318 insertions(+), 125 deletions(-) diff --git a/.gitignore b/.gitignore index 70fdc9ce1..010f945d7 100644 --- a/.gitignore +++ b/.gitignore @@ -41,8 +41,7 @@ compile_commands.json [Bb]uild*/ .ccache/ .vcpkg-binary-cache/ -# CLion-style build dirs: cmake-build-debug/, cmake-build-debug-vcpkg/, cmake-build-release/, ... -cmake-build-*/ +cmake-build-debug/ examples/multi_index_example/build examples/hello/build @@ -68,14 +67,3 @@ tmp/ # oh-my-claudecode runtime state (operational artifacts, never committed) .omc/ - -# prequel local review state (operational artifacts, never committed) -.prequel/ - -# Core dumps. Restricted to the shapes the kernel actually writes here -- core_pattern is -# core.%e.%p -- and root-anchored, so neither a tracked header such as -# libraries/boost/include/boost/hana/core.hpp nor a future core.cpp/core.hpp is hidden. -/core -/core.[0-9]* -/core.*.[0-9]* -/vgcore.[0-9]* diff --git a/CLAUDE.md b/CLAUDE.md index 67a821fc3..3787d8285 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -215,7 +215,7 @@ $BUILD_DIR/tests/unit/kv_cached_tests Adding a unit test needs **two** registrations: `add_cdt_unit_test()` in `tests/unit/CMakeLists.txt` (builds it) and `add_unit_test()` in `tests/CMakeLists.txt` (registers it with ctest). Miss the second and the test still compiles but `ctest` never executes it — a silent gap, not a failure. `basic_name_tests` sat that way until it was registered; when adding a test, check both lists match. -A few unit tests are shell scripts registered directly in `tests/CMakeLists.txt`: `version_tests`, `abi_version_tests`, `multidir_contract_tests`, `postpass_tests`. +A few unit tests are shell scripts registered directly in `tests/CMakeLists.txt`: `version_tests`, `abi_version_tests`, `abidiff_tests`, `multidir_contract_tests`, `postpass_tests`, `staged_headers_tests`. ### Toolchain tests diff --git a/cmake/InstallCDT.cmake b/cmake/InstallCDT.cmake index e82102fa8..3402afb19 100644 --- a/cmake/InstallCDT.cmake +++ b/cmake/InstallCDT.cmake @@ -45,6 +45,7 @@ macro( cdt_libraries_install) # variants installed from ${CMAKE_BINARY_DIR}/packaging (see CMakeLists.txt). install(DIRECTORY ${CMAKE_BINARY_DIR}/lib/ DESTINATION lib COMPONENT base PATTERN "libnative*" EXCLUDE + PATTERN "libsf.a" EXCLUDE PATTERN "cmake" EXCLUDE) # Guarded on the option, not merely on what happens to be sitting in lib/: a tree # reconfigured from native ON to OFF can still hold archives from the previous build. diff --git a/cmake/stage_cdt_tree.cmake b/cmake/stage_cdt_tree.cmake index c1490d4c9..ab87546e7 100644 --- a/cmake/stage_cdt_tree.cmake +++ b/cmake/stage_cdt_tree.cmake @@ -14,9 +14,17 @@ # from one ordered script is what makes that safe -- two independent steps would race # to delete each other's output. # -# `file(COPY)` preserves source timestamps and skips files already current at the -# destination, so re-running every build neither churns mtimes nor triggers -# downstream rebuilds. +# `file(COPY)` preserves source timestamps, so re-running every build neither churns +# mtimes nor triggers downstream rebuilds. (It also skips files already current at the +# destination, but that never applies here: both destinations are REMOVE_RECURSE'd +# below before either is repopulated.) +# +# Inputs (via -D): +# EVERY tree staged into /include is handled here. The four vendored ones -- libc, +# libcxx, boost/preprocessor and bluegrass -- were left as configure-time copies in an +# earlier revision, which meant deleting a header from the cdt-musl or cdt-libcxx submodule +# left the staged copy shipping forever, exactly the bug this script exists to fix. They are +# pruned and recopied on the same schedule now. # # Inputs (via -D): # STAGE_SOURCE_DIR - the repo's libraries/ directory @@ -39,6 +47,10 @@ set(header_patterns FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp") # to vanish with its parent; include/sysio/native has no such parent.) file(REMOVE_RECURSE "${STAGE_BINARY_DIR}/include/sysiolib") file(REMOVE_RECURSE "${STAGE_BINARY_DIR}/include/sysio/native") +file(REMOVE_RECURSE "${STAGE_BINARY_DIR}/include/libc") +file(REMOVE_RECURSE "${STAGE_BINARY_DIR}/include/libcxx") +file(REMOVE_RECURSE "${STAGE_BINARY_DIR}/include/boost/preprocessor") +file(REMOVE_RECURSE "${STAGE_BINARY_DIR}/include/bluegrass") # sysiolib -> include/sysiolib file(COPY "${STAGE_SOURCE_DIR}/sysiolib" @@ -68,3 +80,15 @@ if(STAGE_NATIVE) DESTINATION "${STAGE_BINARY_DIR}/include/sysiolib" ${header_patterns} PATTERN "softfloat" EXCLUDE) endif() + +# The vendored trees. libc and libcxx copy whole directories rather than header-matching, +# because musl and libc++ both ship extensionless headers (, , ...) that a +# "*.h;*.hpp" filter would drop. +file(COPY "${STAGE_SOURCE_DIR}/libc/cdt-musl/include/" DESTINATION "${STAGE_BINARY_DIR}/include/libc/") +file(COPY "${STAGE_SOURCE_DIR}/libc/cdt-musl/src/internal/" DESTINATION "${STAGE_BINARY_DIR}/include/libc/") +file(COPY "${STAGE_SOURCE_DIR}/libc/cdt-musl/arch/eos/" DESTINATION "${STAGE_BINARY_DIR}/include/libc/") +file(COPY "${STAGE_SOURCE_DIR}/libc++/cdt-libcxx/include/" DESTINATION "${STAGE_BINARY_DIR}/include/libcxx") +file(COPY "${STAGE_SOURCE_DIR}/boost/include/boost/preprocessor" + DESTINATION "${STAGE_BINARY_DIR}/include/boost") +file(COPY "${STAGE_SOURCE_DIR}/meta_refl/include/bluegrass" + DESTINATION "${STAGE_BINARY_DIR}/include") diff --git a/imports/cdt.imports.in b/imports/cdt.imports.in index 179f3de6b..6f3f735cd 100644 --- a/imports/cdt.imports.in +++ b/imports/cdt.imports.in @@ -70,7 +70,6 @@ send_inline set_action_return_value set_blockchain_parameters_packed set_finalizers -set_kv_parameters_packed set_privileged set_proposed_producers set_proposed_producers_ex diff --git a/libraries/CMakeLists.txt b/libraries/CMakeLists.txt index a5627d6cf..0949ec546 100644 --- a/libraries/CMakeLists.txt +++ b/libraries/CMakeLists.txt @@ -41,11 +41,26 @@ if (ENABLE_NATIVE_COMPILER) endif() # Anything compiled against the staged tree must see the pruned copy, not a leftover. -foreach(staged_consumer sysio native native_sysio) - if(TARGET ${staged_consumer}) - add_dependencies(${staged_consumer} stage_cdt_tree) - endif() -endforeach() - -file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/boost/include/boost/preprocessor DESTINATION ${BASE_BINARY_DIR}/include/boost) -file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/meta_refl/include/bluegrass DESTINATION ${BASE_BINARY_DIR}/include) +# +# Enumerated, not listed by hand. The staging step REMOVE_RECURSE's its destinations and every +# compile in this tree carries -I/include/... baked in by the driver, so a target that +# is not ordered after it can race the deletion. A three-name allowlist covered `sysio`, +# `native` and `native_sysio` and silently missed sysio_malloc, sysio_dsm, sysio_cmem, c, c++, +# rt, sf and the native_* variants -- and would miss the next one added. +function(cdt_order_after_staging dir) + get_property(dir_targets DIRECTORY "${dir}" PROPERTY BUILDSYSTEM_TARGETS) + foreach(tgt IN LISTS dir_targets) + if(NOT tgt STREQUAL "stage_cdt_tree") + get_target_property(tgt_type ${tgt} TYPE) + if(NOT tgt_type STREQUAL "INTERFACE_LIBRARY") + add_dependencies(${tgt} stage_cdt_tree) + endif() + endif() + endforeach() + get_property(subdirs DIRECTORY "${dir}" PROPERTY SUBDIRECTORIES) + foreach(subdir IN LISTS subdirs) + cdt_order_after_staging("${subdir}") + endforeach() +endfunction() +cdt_order_after_staging("${CMAKE_CURRENT_SOURCE_DIR}") + diff --git a/libraries/libc++/CMakeLists.txt b/libraries/libc++/CMakeLists.txt index b06da22b8..bb121b408 100644 --- a/libraries/libc++/CMakeLists.txt +++ b/libraries/libc++/CMakeLists.txt @@ -44,4 +44,3 @@ if (ENABLE_NATIVE_COMPILER) add_custom_command( TARGET native_c++ POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy $ ${BASE_BINARY_DIR}/lib ) endif() -file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/cdt-libcxx/include/ DESTINATION ${BASE_BINARY_DIR}/include/libcxx) diff --git a/libraries/libc/CMakeLists.txt b/libraries/libc/CMakeLists.txt index f2be30d12..77c20666f 100644 --- a/libraries/libc/CMakeLists.txt +++ b/libraries/libc/CMakeLists.txt @@ -92,6 +92,3 @@ if (ENABLE_NATIVE_COMPILER) add_custom_command( TARGET native_c POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy $ ${BASE_BINARY_DIR}/lib ) endif() -file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/cdt-musl/include/ DESTINATION ${BASE_BINARY_DIR}/include/libc/) -file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/cdt-musl/src/internal/ DESTINATION ${BASE_BINARY_DIR}/include/libc/) -file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/cdt-musl/arch/eos/ DESTINATION ${BASE_BINARY_DIR}/include/libc/) diff --git a/libraries/sysiolib/capi/sysio/privileged.h b/libraries/sysiolib/capi/sysio/privileged.h index 262c9b7a7..54606c04f 100644 --- a/libraries/sysiolib/capi/sysio/privileged.h +++ b/libraries/sysiolib/capi/sysio/privileged.h @@ -105,16 +105,6 @@ void set_blockchain_parameters_packed( char* data, uint32_t datalen ); __attribute__((sysio_wasm_import)) uint32_t get_blockchain_parameters_packed( char* data, uint32_t datalen ); -/** - * Set the KV parameters - * - * @param data - pointer to KV parameters packed as bytes - * @param datalen - size of the packed KV parameters - * @pre `data` is a valid pointer to a range of memory at least `datalen` bytes long that contains packed KV params data - */ -__attribute__((sysio_wasm_import)) -void set_kv_parameters_packed( const char* data, uint32_t datalen ); - /** * Pre-activate protocol feature * diff --git a/tests/unit/abi_version_tests.sh b/tests/unit/abi_version_tests.sh index 6af32920f..a6620ac00 100755 --- a/tests/unit/abi_version_tests.sh +++ b/tests/unit/abi_version_tests.sh @@ -258,6 +258,66 @@ merge_case() { merge_case "older-first" old new 1.1 merge_case "newer-first" new old 1.1 +# --- matcher identity --------------------------------------------------------------- +# +# variant_is_same asked only whether every type in one variant appeared somewhere in the +# other, with no length check, so ["uint64"] and ["uint64","string"] compared equal. Merging +# them kept the accumulator's shorter list and dropped the `string` alternative outright -- +# or, with the descriptors in the other order, failed the build with "v already defined". +# Which of the two you got was decided by sorted .desc filename order. +# +# struct_is_same matched fields by set membership plus size, so the same struct declared with +# reordered fields merged as identical and the alphabetically-first .desc silently won. ABI +# field order is serialization order, so that is a wire-layout change decided by a filename. +VAR_COMMON='"types":[],"tables":[],"ricardian_clauses":[],"abi_extensions":[],"pb_types":[],"wasm_actions":[],"wasm_entries":[],"wasm_notifies":[],"action_results":[]' + +mkdesc_variant() { # $1=path $2=types-json + cat > "$1" < "$1" < "${dir}/mix.log" 2>&1; then + fail "${label} (${order} order merged instead of refusing)" + sed 's/^/ /' "${dir}/mix.abi" + else + pass "${label} (${order} order)" + fi + done +} + +mkdesc_variant "${WORK}/v_short.desc" '["uint64"]' +mkdesc_variant "${WORK}/v_long.desc" '["uint64","string"]' +merge_refuses_both_orders "variants of differing length conflict" \ + "${WORK}/v_short.desc" "${WORK}/v_long.desc" + +mkdesc_struct "${WORK}/s_ab.desc" '[{"name":"a","type":"uint64"},{"name":"b","type":"string"}]' +mkdesc_struct "${WORK}/s_ba.desc" '[{"name":"b","type":"string"},{"name":"a","type":"uint64"}]' +merge_refuses_both_orders "reordered struct fields conflict" \ + "${WORK}/s_ab.desc" "${WORK}/s_ba.desc" + # A descriptor missing a REQUIRED section is truncated, not merely older, and must be # rejected rather than merged as empty -- otherwise contract interface content is dropped # silently. Baseline sections are required at every version; `variants` and `action_results` diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index e0289a134..dc12897e5 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -104,7 +104,7 @@ fi # Identical inputs must stay quiet. if ! capture "identical ABIs report no difference" "${WORK}/v1.abi" "${WORK}/v1.abi"; then : -elif grep -qE "version|action_results" <<< "$out"; then +elif grep -qE "^[<>] (version|struct|type|action|table|clause|variant|action_result)" <<< "$out"; then fail "identical ABIs report no difference" sed 's/^/ /' <<< "$out" else @@ -192,6 +192,85 @@ else pass "identical 1.10 variants report no difference" fi +# --- struct matching ------------------------------------------------------------------- +# +# find_structs kept a success flag across its field loop and broke out of it on a mismatch +# without clearing it, so only a difference in the FIRST field was ever detected. It also +# seeded the flag false and set it only inside that loop, so two identical zero-field +# structs -- which every parameterless action generates -- compared as different. + +mkstruct() { # $1=path $2=fields JSON + cat > "$1" < "$1" <= abi_version::protobuf_minor || - abi_version_major != abi_version::default_major); - abi["version"] = abi_version::version_string(abi_version_major, abi_version_minor); + // stamp the merged document. + // + // Take the NEWER of the CLI version and the version the descriptors merged to. + // Stamping the CLI version unconditionally downgraded a document whose + // descriptors declared something newer -- reachable through the fallback scan + // that picks up .desc files from earlier compiles, which may have run with a + // different -abi-version. The previous assert() here was tautological (parse() + // bounds the major to exactly max_supported_major, so its second disjunct was + // unreachable) and compiled away under the default Release TOOLS_BUILD_TYPE. + int merged_major = 0; + int merged_minor = 0; + std::pair stamped{abi_version_major, abi_version_minor}; + if (abi.count("version") && + abi_version::parse_version_string(abi["version"].as(), merged_major, merged_minor)) { + stamped = std::max(stamped, std::pair{merged_major, merged_minor}); + } + abi["version"] = abi_version::version_string(stamped.first, stamped.second); } else if (referenced_pb_types.size()) { std::cerr << "protobuf types are used but no protobuf files are specified for contract " << contract_name << ", please use `contract_use_protobuf()` cmake function to specify the protobuf files it depends on\n"; diff --git a/tools/include/sysio/abimerge.hpp b/tools/include/sysio/abimerge.hpp index c1551b8d9..2feca9d3a 100644 --- a/tools/include/sysio/abimerge.hpp +++ b/tools/include/sysio/abimerge.hpp @@ -6,7 +6,9 @@ #include #include "abi.hpp" +#include #include +#include #include #include #include @@ -56,9 +58,14 @@ class ABIMerger { ret["actions"] = merge_actions(other); ret["tables"] = merge_tables(other); ret["ricardian_clauses"] = merge_clauses(other); - ret["variants"] = merge_variants(other); - // Compare parsed components: deriving them from the string's last three - // characters mis-read any two-digit minor ("sysio::abi/1.10" -> ".10"). + // Both version-gated sections consult the MERGED version, and both are compared as + // parsed components: deriving them from the string's last three characters mis-read + // any two-digit minor ("sysio::abi/1.10" -> ".10"). variants was previously emitted + // unconditionally, so a 1.0 document merged at 1.0 produced a 1.0 ABI carrying a + // variants array -- contradicting the variants_since rule declared below. + if (abi_version::supports_variants(merged_version.first, merged_version.second)) { + ret["variants"] = merge_variants(other); + } if (abi_version::supports_action_results(merged_version.first, merged_version.second)) { ret["action_results"] = merge_action_results(other); } @@ -95,20 +102,21 @@ class ABIMerger { : abi["version"].as(); } + // Field order is significant: it is the serialization order, so {x,y} and {y,x} are + // different wire layouts. The previous form matched by set membership plus size, so two + // descriptors declaring the same struct with reordered fields merged as identical and + // whichever .desc sorted first silently won -- a determinism hazard keyed on filename. static bool struct_is_same(ojson a, ojson b) { - bool same_fields = a["fields"].size() == b["fields"].size(); - for (auto a_field : a["fields"].array_range()) { - bool found_field = false; - for (auto b_field : b["fields"].array_range()) { - if (a_field["name"] == b_field["name"] && - a_field["type"] == b_field["type"]) - found_field = true; - } - if (!found_field) + if (a["name"] != b["name"] || a["base"] != b["base"]) + return false; + const auto& fa = a["fields"]; + const auto& fb = b["fields"]; + if (fa.size() != fb.size()) + return false; + for (size_t i = 0; i < fa.size(); ++i) + if (fa[i]["name"] != fb[i]["name"] || fa[i]["type"] != fb[i]["type"]) return false; - } - return a["name"] == b["name"] && - a["base"] == b["base"] && same_fields; + return true; } static bool type_is_same(ojson a, ojson b) { @@ -122,37 +130,38 @@ class ABIMerger { a["ricardian_contract"] == b["ricardian_contract"]; } - template - static bool action_is_almost_same(ojson a, ojson b, T& rc) { - if (a["ricardian_contract"].empty()) - rc = b["ricardian_contract"]; - return a["name"] == b["name"] && - a["type"] == b["type"]; - } - + // Length and order, like struct_is_same and like cdt-abidiff's find_variants. The + // previous form asked only whether every type in `a` appeared somewhere in `b`, so + // ["uint64"] and ["uint64","string"] compared equal: merging them kept the accumulator's + // shorter list and dropped the `string` alternative outright, or -- with the descriptors + // in the other order -- failed the build with "v already defined". Which of the two you + // got was decided by sorted .desc filename order. static bool variant_is_same(ojson a, ojson b) { - for (auto tya : a["types"].array_range()) { - bool found_ty = false; - for (auto tyb : b["types"].array_range()) { - if (tyb == tya) - found_ty = true; - } - if (!found_ty) + if (a["name"] != b["name"]) + return false; + const auto& ta = a["types"]; + const auto& tb = b["types"]; + if (ta.size() != tb.size()) + return false; + for (size_t i = 0; i < ta.size(); ++i) + if (ta[i] != tb[i]) return false; - } - return a["name"] == b["name"]; + return true; } static bool table_is_same(ojson a, ojson b) { // key_names/key_types may differ: template-detected tables have them // populated while attribute-only tables have empty arrays. Both are // valid representations of the same table — treat as compatible. + const auto compatible = [](const ojson& x, const ojson& y) { + return x == y || x.empty() || y.empty(); + }; return a["name"] == b["name"] && a["type"] == b["type"] && a["index_type"] == b["index_type"] && - (a["key_names"] == b["key_names"] || - a["key_names"].empty() || b["key_names"].empty()); + compatible(a["key_names"], b["key_names"]) && + compatible(a["key_types"], b["key_types"]); } static bool clause_is_same(ojson a, ojson b) { @@ -303,10 +312,14 @@ class ABIMerger { ojson abi; }; +// max_supported_major, not default_major: these say which version of the FORMAT introduced the +// section, which is a property of the format, not of what this toolchain happens to emit by +// default. The two are equal today, so bumping the emission default would silently move every +// threshold with it. inline const ABIMerger::section_since ABIMerger::variants_since{ - std::pair{abi_version::default_major, abi_version::variants_minor}}; + std::pair{abi_version::max_supported_major, abi_version::variants_minor}}; inline const ABIMerger::section_since ABIMerger::action_results_since{ - std::pair{abi_version::default_major, abi_version::action_results_minor}}; + std::pair{abi_version::max_supported_major, abi_version::action_results_minor}}; inline const ABIMerger::section_since ABIMerger::never_mandatory{}; #pragma GCC diagnostic pop From d0c406f39ae72a6619faee45aed6d5d791d0c3d4 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 1 Sep 2026 12:42:53 -0500 Subject: [PATCH 12/26] chore: fix regressions from the previous round and finish the matcher sweep Pre-push review found four blockers, three of them introduced by the last commit. Each is a case where I took a review claim at face value instead of checking it. - libsf.a is a WASM archive, not a native one. `llvm-ar x` on it yields objects that `file` reports as "WebAssembly (wasm) binary module", and compiler_options.hpp.in:578 links it with -lsf for --use-rt and --fquery*. Excluding it from the base install put it in NO component -- the dev install is FILES_MATCHING "libnative*", which does not match it -- so every deb, rpm and tarball would have shipped a sysroot that cannot link a --use-rt contract. It only looks native-only because add_library(sf ...) happens to sit in libraries/native/CMakeLists.txt. Exclude reverted. - tables_match read table_id and index_type through jsoncons' const operator[], which throws on an absent key. Those are Wire extensions, so a stock Antelope ABI has neither and cdt-abidiff aborted with exit 255 on any such file -- including two byte-identical ones. The new mktable fixtures always wrote all five fields, so the suite could not see it. Every optional field now reads through field_or_null, and structs_match and find_variants get the same treatment for `base` and `types`. - Gating `variants` on the merged version DROPPED it. abigen emits variants unconditionally at every version, so a contract with a std::variant parameter built at -abi-version 1.0 kept the struct field typed `variant_uint64_string` while the array defining it was discarded: an ABI referencing a type it does not define. A populated gated section now PROMOTES the emitted version instead, as the protobuf path already does for 1.3. Promotion happens before any section is emitted, so the result does not depend on the order sections are considered in. - print_clause read abi["clauses"] while find_clauses iterates abi["ricardian_clauses"], so the tool threw the moment it had a clause difference to report. Pre-existing; it means cdt-abidiff has never been able to report one. A fifth, caught by the toolchain fixtures rather than the reviewer: ojson preserves insertion order and "version" is the first key of every ABI this toolchain emits, so assigning it after the sections moved it to the end of the object and changed the bytes of all 16 abigen-pass fixtures. It is inserted first and corrected in place. Also from the review: - ABIMerger::table_is_same still ignored table_id and secondary_indexes while cdt-abidiff's tables_match compared them -- the differ and the merger disagreeing on table identity, and the same "fixed one sibling" pattern this work exists to end. Both now compare them. - cdt-abidiff compared `enums` and `protobuf_types` nowhere at all: an enum's values or an entire protobuf descriptor could change and it reported no difference. - find_types, find_actions, find_clauses and find_action_results kept the old stale-flag shape; they use the same continue-on-name-mismatch form as the three rewritten ones. - The "changed table key_names" fixture varied key_names AND key_types, so deleting the key_names comparison left the suite green. It varies key_names alone now, and a row-type fixture pins `type`, which was also unpinned. - staged_headers_tests.sh said the vendored trees were out of scope and its source_for() had no case for them, so the previous commit's headline change shipped with no automated coverage. It maps all six trees now, without an extension filter, since libc++ ships extensionless headers -- 575 staged files checked, and planting an orphan in libc, libcxx or bluegrass fails it. Tests: abidiff_tests 33 assertions, abi_version_tests 43, staged_headers 5. Ablating the key_names comparison fails exactly one case; ctest 31/31. --- cmake/InstallCDT.cmake | 1 - cmake/stage_cdt_tree.cmake | 1 - tests/unit/abi_version_tests.sh | 37 ++++++++ tests/unit/abidiff_tests.sh | 128 +++++++++++++++++++++++++- tests/unit/staged_headers_tests.sh | 41 +++++++-- tools/abidiff/cdt-abidiff.cpp.in | 140 +++++++++++++++++++++-------- tools/include/sysio/abimerge.hpp | 79 ++++++++++++---- 7 files changed, 361 insertions(+), 66 deletions(-) diff --git a/cmake/InstallCDT.cmake b/cmake/InstallCDT.cmake index 3402afb19..e82102fa8 100644 --- a/cmake/InstallCDT.cmake +++ b/cmake/InstallCDT.cmake @@ -45,7 +45,6 @@ macro( cdt_libraries_install) # variants installed from ${CMAKE_BINARY_DIR}/packaging (see CMakeLists.txt). install(DIRECTORY ${CMAKE_BINARY_DIR}/lib/ DESTINATION lib COMPONENT base PATTERN "libnative*" EXCLUDE - PATTERN "libsf.a" EXCLUDE PATTERN "cmake" EXCLUDE) # Guarded on the option, not merely on what happens to be sitting in lib/: a tree # reconfigured from native ON to OFF can still hold archives from the previous build. diff --git a/cmake/stage_cdt_tree.cmake b/cmake/stage_cdt_tree.cmake index ab87546e7..de45f76ec 100644 --- a/cmake/stage_cdt_tree.cmake +++ b/cmake/stage_cdt_tree.cmake @@ -19,7 +19,6 @@ # destination, but that never applies here: both destinations are REMOVE_RECURSE'd # below before either is repopulated.) # -# Inputs (via -D): # EVERY tree staged into /include is handled here. The four vendored ones -- libc, # libcxx, boost/preprocessor and bluegrass -- were left as configure-time copies in an # earlier revision, which meant deleting a header from the cdt-musl or cdt-libcxx submodule diff --git a/tests/unit/abi_version_tests.sh b/tests/unit/abi_version_tests.sh index a6620ac00..e06178d41 100755 --- a/tests/unit/abi_version_tests.sh +++ b/tests/unit/abi_version_tests.sh @@ -308,6 +308,43 @@ merge_refuses_both_orders() { done } +# A populated gated section PROMOTES the emitted version rather than being dropped. abigen +# writes `variants` at every version, so gating the merge output on the requested version +# discarded the array while the struct field still referenced `variant_uint64_string` -- an +# ABI naming a type it does not define. Promotion is what the protobuf path already does. +# Asserted end to end, since the interesting part is abigen and the merger agreeing. +promo_dir="${WORK}/promote_1_0"; mkdir -p "$promo_dir" +cat > "${promo_dir}/v.cpp" <<'EOF' +#include +#include +#include +using namespace sysio; +class [[sysio::contract]] v : public contract { public: using contract::contract; + [[sysio::action]] void go(std::variant p) { (void)p; } +}; +EOF +if (cd "$promo_dir" && "${BUILD_DIR}/bin/cdt-cpp" -abigen -abigen_output=v.abi -contract=v \ + -abi-version 1.0 -o v.wasm v.cpp) > "${promo_dir}/build.log" 2>&1; then + check "a variant at -abi-version 1.0 promotes to 1.1" \ + "${promo_dir}/v.abi" '"version": "sysio::abi/1.1"' + check "the promoted document still defines the variant it references" \ + "${promo_dir}/v.abi" '"name": "variant_uint64_string"' +else + fail "a variant at -abi-version 1.0 builds" + sed 's/^/ /' "${promo_dir}/build.log" +fi + +# "version" is the first key of every ABI this toolchain emits and ojson preserves insertion +# order, so assigning it after the sections moved it to the end of the object -- changing the +# bytes of every contract's ABI. The abigen-pass fixtures pin this too; asserted here as well +# because the merger is where the ordering is decided. +if [ "$(head -3 "${promo_dir}/v.abi" | grep -c '"version"')" -eq 1 ]; then + pass "version stays the leading key of the emitted ABI" +else + fail "version stays the leading key of the emitted ABI" + sed 's/^/ /' <<< "$(head -3 "${promo_dir}/v.abi")" +fi + mkdesc_variant "${WORK}/v_short.desc" '["uint64"]' mkdesc_variant "${WORK}/v_long.desc" '["uint64","string"]' merge_refuses_both_orders "variants of differing length conflict" \ diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index dc12897e5..42d4b12b3 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -261,16 +261,142 @@ EOF mktable "${WORK}/t_base.abi" i64 '["id"]' '["uint64"]' 100 mktable "${WORK}/t_idx.abi" kv64 '["id"]' '["uint64"]' 100 -mktable "${WORK}/t_names.abi" i64 '["owner","id"]' '["name","uint64"]' 100 +# key_names alone -- an earlier version varied key_types at the same time, so deleting the +# key_names comparison left the suite green. +mktable "${WORK}/t_names.abi" i64 '["owner"]' '["uint64"]' 100 mktable "${WORK}/t_types.abi" i64 '["id"]' '["name"]' 100 mktable "${WORK}/t_id.abi" i64 '["id"]' '["uint64"]' 200 +cat > "${WORK}/t_type.abi" < "${WORK}/t_sec_a.abi" <<'EOF' +{ + "version": "sysio::abi/1.2", + "types": [], "structs": [], "actions": [], "ricardian_clauses": [], "variants": [], + "action_results": [], + "tables": [ { "name": "t", "type": "row", "index_type": "i64", + "key_names": ["id"], "key_types": ["uint64"], "table_id": 100, + "secondary_indexes": [ { "name": "byowner", "type": "name", "table_id": 37799 } ] } ] +} +EOF +sed 's/37799/60481/' "${WORK}/t_sec_a.abi" > "${WORK}/t_sec_b.abi" +expect_reports "a changed secondary index table_id is reported" \ + "${WORK}/t_sec_a.abi" "${WORK}/t_sec_b.abi" "table" +expect_quiet "an identical table with secondary indexes reports no difference" \ + "${WORK}/t_sec_a.abi" "${WORK}/t_sec_a.abi" "table" + +# --- optional keys --------------------------------------------------------------------- +# +# table_id, index_type and secondary_indexes are Wire extensions: a stock Antelope/eosio-cdt +# ABI carries none of them. Reading an absent key through jsoncons' const operator[] throws, +# so comparing them naively aborted the tool (exit 255) on every such ABI -- including two +# byte-identical ones. capture() already fails a case whose process exits non-zero, so these +# assert the comparison happens at all, not merely that it is quiet. +cat > "${WORK}/t_antelope.abi" <<'EOF' +{ + "version": "sysio::abi/1.2", + "types": [], "structs": [], "actions": [], "ricardian_clauses": [], "variants": [], + "action_results": [], + "tables": [ { "name": "t", "type": "row", "index_type": "i64", + "key_names": ["id"], "key_types": ["uint64"] } ] +} +EOF +expect_quiet "an ABI with no table_id diffs cleanly against itself" \ + "${WORK}/t_antelope.abi" "${WORK}/t_antelope.abi" "table" +expect_reports "an ABI with no table_id differs from one that has it" \ + "${WORK}/t_antelope.abi" "${WORK}/t_base.abi" "table" + +cat > "${WORK}/t_minimal.abi" <<'EOF' +{ + "version": "sysio::abi/1.2", + "types": [], "structs": [], "actions": [], "ricardian_clauses": [], "variants": [], + "action_results": [], + "tables": [ { "name": "t", "type": "row" } ] +} +EOF +expect_quiet "a name+type-only table diffs cleanly against itself" \ + "${WORK}/t_minimal.abi" "${WORK}/t_minimal.abi" "table" + +# A struct with no "base" key, as ABIs from other toolchains emit. +cat > "${WORK}/s_nobase.abi" <<'EOF' +{ + "version": "sysio::abi/1.2", + "types": [], "actions": [], "tables": [], "ricardian_clauses": [], "variants": [], + "action_results": [], + "structs": [ { "name": "s", "fields": [ {"name":"a","type":"uint64"} ] } ] +} +EOF +expect_quiet "a struct with no base key diffs cleanly against itself" \ + "${WORK}/s_nobase.abi" "${WORK}/s_nobase.abi" "struct" + +# --- ricardian clauses ----------------------------------------------------------------- +# +# find_clauses iterates "ricardian_clauses" but print_clause read "clauses", so the tool +# aborted the moment it had a clause difference to report -- it could never report one. +mkclause() { # $1=path $2=body + cat > "$1" < "$1" < "$1" < <- libraries/native/ # # The sysiolib/native rule is tested first because it is the more specific prefix. +# Maps a staged path back to the source it was copied from, for every tree +# stage_cdt_tree.cmake owns. The vendored four were added when staging took them over from +# their configure-time copies; leaving them out of this map is what let that half of the +# rework ship untested. source_for() { local staged="$1" case "$staged" in - sysiolib/native/*) echo "${SOURCE_DIR}/libraries/native/native/${staged#sysiolib/native/}" ;; - sysiolib/*) echo "${SOURCE_DIR}/libraries/${staged}" ;; - sysio/native/*) echo "${SOURCE_DIR}/libraries/native/${staged#sysio/native/}" ;; - *) echo "" ;; + sysiolib/native/*) echo "${SOURCE_DIR}/libraries/native/native/${staged#sysiolib/native/}" ;; + sysiolib/*) echo "${SOURCE_DIR}/libraries/${staged}" ;; + sysio/native/*) echo "${SOURCE_DIR}/libraries/native/${staged#sysio/native/}" ;; + libcxx/*) echo "${SOURCE_DIR}/libraries/libc++/cdt-libcxx/include/${staged#libcxx/}" ;; + bluegrass/*) echo "${SOURCE_DIR}/libraries/meta_refl/include/${staged}" ;; + boost/preprocessor/*) echo "${SOURCE_DIR}/libraries/boost/include/${staged}" ;; + # libc is stitched together from three source roots, so a staged file legitimately + # matches any one of them; first hit wins. + libc/*) + local rest="${staged#libc/}" root + for root in "libraries/libc/cdt-musl/include" \ + "libraries/libc/cdt-musl/src/internal" \ + "libraries/libc/cdt-musl/arch/eos"; do + [ -f "${SOURCE_DIR}/${root}/${rest}" ] && { echo "${SOURCE_DIR}/${root}/${rest}"; return; } + done + # Not found under any root -- report the first so the failure names a real path. + echo "${SOURCE_DIR}/libraries/libc/cdt-musl/include/${rest}" + ;; + *) echo "" ;; esac } @@ -54,8 +73,8 @@ if [ ! -d "$INCLUDE_DIR" ]; then exit 1 fi -# Only the CDT-owned trees are checked. The vendored trees (libc, libcxx, boost, -# bluegrass) are still staged by their own configure-time copies and are out of scope. +# Every tree stage_cdt_tree.cmake owns, vendored ones included. A staged file with no source +# counterpart is one the pruning step failed to remove -- the whole point of the rework. staged_count=0 stale=() while IFS= read -r abs; do @@ -64,8 +83,14 @@ while IFS= read -r abs; do [ -z "$src" ] && continue staged_count=$((staged_count + 1)) [ -f "$src" ] || stale+=("$rel") -done < <(find "${INCLUDE_DIR}/sysiolib" "${INCLUDE_DIR}/sysio" \ - \( -name '*.h' -o -name '*.hpp' \) -type f 2>/dev/null) +# No extension filter on the vendored trees: libc++ ships extensionless headers (, +# ), which a '*.h;*.hpp' find would skip entirely -- and those are exactly the files +# a whole-directory copy stages and an extension-filtered one would have dropped. +done < <( { find "${INCLUDE_DIR}/sysiolib" "${INCLUDE_DIR}/sysio" \ + \( -name '*.h' -o -name '*.hpp' \) -type f 2>/dev/null + find "${INCLUDE_DIR}/libc" "${INCLUDE_DIR}/libcxx" \ + "${INCLUDE_DIR}/boost/preprocessor" "${INCLUDE_DIR}/bluegrass" \ + -type f 2>/dev/null; } ) if [ "$staged_count" -eq 0 ]; then fail "found staged CDT headers to check (none under ${INCLUDE_DIR})" diff --git a/tools/abidiff/cdt-abidiff.cpp.in b/tools/abidiff/cdt-abidiff.cpp.in index 83d102640..6c4bb7f61 100644 --- a/tools/abidiff/cdt-abidiff.cpp.in +++ b/tools/abidiff/cdt-abidiff.cpp.in @@ -90,6 +90,15 @@ class abidiff { std::cout << pretty_print(abi["structs"].at(index)) << "\n"; } + // An absent key read through the CONST operator[] throws, and ABIs legitimately omit + // keys: table_id, index_type and secondary_indexes are Wire extensions, so a stock + // Antelope ABI carries none of them. Every optional field is read through this, so + // absent compares equal to absent and never equal to present. + static const ojson& field_or_null(const ojson& o, const char* key) { + static const ojson absent = ojson::null(); + return o.count(key) ? o.at(key) : absent; + } + // Element-wise equality over a JSON array. Used for the ABI's list-valued fields, where // both length and order are significant -- order is serialization order. static bool arrays_equal(const ojson& a, const ojson& b) { @@ -110,10 +119,10 @@ class abidiff { // field but the first left the flag set from the previous iteration and the changed // struct was reported as unchanged. static bool structs_match(const ojson& a, const ojson& b) { - if (a["base"] != b["base"]) + if (field_or_null(a, "base") != field_or_null(b, "base")) return false; - const auto& fa = a["fields"]; - const auto& fb = b["fields"]; + const auto& fa = field_or_null(a, "fields"); + const auto& fb = field_or_null(b, "fields"); if (fa.size() != fb.size()) return false; for (size_t k = 0; k < fa.size(); ++k) @@ -145,11 +154,12 @@ class abidiff { for ( int i=0; i < abi1["types"].size(); i++ ) { bool found = false; for ( int j=0; j < abi2["types"].size(); j++ ) { - if (abi1["types"].at(i)["new_type_name"] == abi2["types"].at(j)["new_type_name"]) { - if (abi1["types"].at(i)["type"] != abi2["types"].at(j)["type"]) - break; - found = true; - } + if (abi1["types"].at(i)["new_type_name"] != abi2["types"].at(j)["new_type_name"]) + continue; + if (field_or_null(abi1["types"].at(i), "type") != field_or_null(abi2["types"].at(j), "type")) + break; + found = true; + break; } if (!found) print_type(abi1, i, direction); @@ -165,13 +175,14 @@ class abidiff { for ( int i=0; i < abi1["actions"].size(); i++ ) { bool found = false; for ( int j=0; j < abi2["actions"].size(); j++ ) { - if (abi1["actions"].at(i)["name"] == abi2["actions"].at(j)["name"]) { - if (abi1["actions"].at(i)["type"] != abi2["actions"].at(j)["type"]) - break; - if (abi1["actions"].at(i)["ricardian_contract"] != abi2["actions"].at(j)["ricardian_contract"]) - break; - found = true; - } + if (abi1["actions"].at(i)["name"] != abi2["actions"].at(j)["name"]) + continue; + if (field_or_null(abi1["actions"].at(i), "type") != field_or_null(abi2["actions"].at(j), "type")) + break; + if (field_or_null(abi1["actions"].at(i), "ricardian_contract") != field_or_null(abi2["actions"].at(j), "ricardian_contract")) + break; + found = true; + break; } if (!found) print_action(abi1, i, direction); @@ -183,16 +194,22 @@ class abidiff { std::cout << pretty_print(abi["tables"].at(index)) << "\n"; } - // Every field a table entry carries. Comparing only name and type -- as this did -- made - // the tool silent about the metadata it exists to check: index_type, key_names, - // key_types and table_id could all change and it reported no difference. table_id in - // particular is where the row physically lives, so a change there is a migration. + // Every field a table entry can carry. Comparing only name and type -- as this did -- + // made the tool silent about the metadata it exists to check: index_type, key_names, + // key_types, table_id and secondary_indexes could all change and it reported no + // difference. table_id is where the row physically lives and each secondary index + // carries its own, so a change to either is a migration. + // + // All of these are optional: read through field_or_null so an ABI that omits them is + // compared, not aborted on. static bool tables_match(const ojson& a, const ojson& b) { - return a["type"] == b["type"] - && a["index_type"] == b["index_type"] - && a["table_id"] == b["table_id"] - && arrays_equal(a["key_names"], b["key_names"]) - && arrays_equal(a["key_types"], b["key_types"]); + for (const char* key : {"type", "index_type", "table_id"}) + if (field_or_null(a, key) != field_or_null(b, key)) + return false; + for (const char* key : {"key_names", "key_types", "secondary_indexes"}) + if (!arrays_equal(field_or_null(a, key), field_or_null(b, key))) + return false; + return true; } void find_tables(const ojson& abi1, const ojson& abi2, char direction) { @@ -211,18 +228,19 @@ class abidiff { void print_clause(const ojson& abi, int index, char direction) { std::cout << direction << " clause\n"; - std::cout << pretty_print(abi["clauses"].at(index)) << "\n"; + std::cout << pretty_print(abi["ricardian_clauses"].at(index)) << "\n"; } void find_clauses(const ojson& abi1, const ojson& abi2, char direction) { for ( int i=0; i < abi1["ricardian_clauses"].size(); i++ ) { bool found = false; for ( int j=0; j < abi2["ricardian_clauses"].size(); j++ ) { - if (abi1["ricardian_clauses"].at(i)["id"] == abi2["ricardian_clauses"].at(j)["id"]) { - if (abi1["ricardian_clauses"].at(i)["body"] != abi2["ricardian_clauses"].at(j)["body"]) - break; - found = true; - } + if (abi1["ricardian_clauses"].at(i)["id"] != abi2["ricardian_clauses"].at(j)["id"]) + continue; + if (field_or_null(abi1["ricardian_clauses"].at(i), "body") != field_or_null(abi2["ricardian_clauses"].at(j), "body")) + break; + found = true; + break; } if (!found) print_clause(abi1, i, direction); @@ -244,7 +262,8 @@ class abidiff { // loop on a mismatch and then set found unconditionally, so a same-named variant // counted as unchanged however its types differed; with no length check, at(k) // also threw on a shorter right-hand side. - found = arrays_equal(abi1["variants"].at(i)["types"], abi2["variants"].at(j)["types"]); + found = arrays_equal(field_or_null(abi1["variants"].at(i), "types"), + field_or_null(abi2["variants"].at(j), "types")); break; } if (!found) @@ -261,14 +280,12 @@ class abidiff { for ( int i=0; i < abi1["action_results"].size(); i++ ) { bool found = false; for ( int j=0; j < abi2["action_results"].size(); j++ ) { - if (abi1["action_results"].at(i)["name"] == abi2["action_results"].at(j)["name"]) { - // .at(j), not .at(i): j is the entry whose name just matched. Comparing - // against .at(i) reported reordered-but-equivalent results as changed, and - // threw outright when abi2 held fewer entries than abi1. - if (abi1["action_results"].at(i)["result_type"] != abi2["action_results"].at(j)["result_type"]) - break; - found = true; - } + if (abi1["action_results"].at(i)["name"] != abi2["action_results"].at(j)["name"]) + continue; + if (field_or_null(abi1["action_results"].at(i), "result_type") != field_or_null(abi2["action_results"].at(j), "result_type")) + break; + found = true; + break; } if (!found) @@ -310,6 +327,49 @@ class abidiff { find_action_results(abi_2, abi_1, '>'); } + void print_enum(const ojson& abi, int index, char direction) { + std::cout << direction << " enum\n"; + std::cout << pretty_print(abi["enums"].at(index)) << "\n"; + } + + // enums and protobuf_types were compared by nothing at all, so a contract could change + // an enum's values or an entire protobuf descriptor and cdt-abidiff reported no + // difference. Both are optional sections -- absent in most ABIs -- so both sides are + // read through field_or_null. + void find_enums(const ojson& abi1, const ojson& abi2, char direction) { + const ojson& e1 = field_or_null(abi1, "enums"); + const ojson& e2 = field_or_null(abi2, "enums"); + for ( size_t i=0; i < e1.size(); i++ ) { + bool found = false; + for ( size_t j=0; j < e2.size(); j++ ) { + if (e1.at(i)["name"] != e2.at(j)["name"]) + continue; + found = field_or_null(e1.at(i), "type") == field_or_null(e2.at(j), "type") + && arrays_equal(field_or_null(e1.at(i), "values"), field_or_null(e2.at(j), "values")); + break; + } + if (!found) + print_enum(abi1, static_cast(i), direction); + } + } + + void diff_enums() { + find_enums(abi_1, abi_2, '<'); + find_enums(abi_2, abi_1, '>'); + } + + // protobuf_types is a whole serialized FileDescriptorSet, not a list keyed by name, so + // it is compared as one value rather than element-wise. A difference anywhere in it + // changes the wire encoding of every protobuf action. + void diff_protobuf_types() { + const ojson& p1 = field_or_null(abi_1, "protobuf_types"); + const ojson& p2 = field_or_null(abi_2, "protobuf_types"); + if (p1 != p2) { + std::cout << "< protobuf_types\n" << pretty_print(p1) << "\n"; + std::cout << "> protobuf_types\n" << pretty_print(p2) << "\n"; + } + } + void diff() { diff_version(); diff_structs(); @@ -317,6 +377,8 @@ class abidiff { diff_actions(); diff_tables(); diff_clauses(); + diff_enums(); + diff_protobuf_types(); const auto [v1_major, v1_minor] = get_version(abi_1, "file1"); const auto [v2_major, v2_minor] = get_version(abi_2, "file2"); if ( abi_version::supports_variants(v1_major, v1_minor) && diff --git a/tools/include/sysio/abimerge.hpp b/tools/include/sysio/abimerge.hpp index 2feca9d3a..5ffa8102b 100644 --- a/tools/include/sysio/abimerge.hpp +++ b/tools/include/sysio/abimerge.hpp @@ -51,24 +51,57 @@ class ABIMerger { // gate below must consult THAT, not just the left-hand side. Gating on the // left alone emitted e.g. 1.10 while dropping the action_results the newer // side carried -- a version stamp promising a section the ABI lacks. - const std::pair merged_version = std::max(version_of(abi), version_of(other)); + std::pair merged_version = std::max(version_of(abi), version_of(other)); + const std::pair declared_version = merged_version; + // Inserted HERE, before any other section, because ojson preserves insertion order + // and "version" is the first key of every ABI this toolchain has ever emitted. + // The value is corrected in place below once promotion is known; overwriting an + // existing key keeps its position, whereas assigning it late would move it to the + // end of the object and change the bytes of every contract's ABI. ret["version"] = merge_version(other); ret["types"] = merge_types(other); ret["structs"] = merge_structs(other); ret["actions"] = merge_actions(other); ret["tables"] = merge_tables(other); ret["ricardian_clauses"] = merge_clauses(other); - // Both version-gated sections consult the MERGED version, and both are compared as - // parsed components: deriving them from the string's last three characters mis-read - // any two-digit minor ("sysio::abi/1.10" -> ".10"). variants was previously emitted - // unconditionally, so a 1.0 document merged at 1.0 produced a 1.0 ABI carrying a - // variants array -- contradicting the variants_since rule declared below. - if (abi_version::supports_variants(merged_version.first, merged_version.second)) { - ret["variants"] = merge_variants(other); - } - if (abi_version::supports_action_results(merged_version.first, merged_version.second)) { - ret["action_results"] = merge_action_results(other); - } + + // A section belongs to the emitted document if it has content, and the emitted + // VERSION is then raised to one that admits it. Gating the other way -- dropping a + // populated section because the requested version predates it -- emits a document + // that references a type it does not define: abigen writes `variants` + // unconditionally, so a contract with a std::variant parameter built at + // -abi-version 1.0 had its struct field still typed `variant_uint64_string` while + // the variants array itself was silently discarded. Promoting is what the protobuf + // path already does when it moves a document to 1.3. + // Promote FIRST, from every gated section, then emit -- so the outcome does not + // depend on the order the sections are considered in. (Emitting as we go meant a + // populated action_results could raise the version to 1.2 after an empty variants + // had already been skipped, leaving a 1.2 document missing a section 1.2 requires.) + ojson variants_section = merge_variants(other); + ojson results_section = merge_action_results(other); + if (!variants_section.empty() && variants_since) + merged_version = std::max(merged_version, *variants_since); + if (!results_section.empty() && action_results_since) + merged_version = std::max(merged_version, *action_results_since); + + // A gated section is emitted when it has content, or when the version requires it to + // be present -- an empty array is the correct representation in that second case, + // and section() rejects a document that omits it. Below its version, absent. + const auto emit_section = [&](const char* key, ojson section, + const section_since& since) { + if (!section.empty() || (since && merged_version >= *since)) + ret[key] = std::move(section); + }; + emit_section("variants", std::move(variants_section), variants_since); + emit_section("action_results", std::move(results_section), action_results_since); + + // Corrected in place (keeping its leading position) only if emit_section raised the + // version above what either input declared. When nothing forced a promotion the + // newer document's own version STRING stands -- parse ignores the namespace prefix, + // so an inherited "eosio::abi/1.2" is valid and rewriting it to "sysio::abi/" would + // be a silent change to every merged document. + if (merged_version != declared_version) + ret["version"] = abi_version::version_string(merged_version.first, merged_version.second); { ojson merged_enums = merge_enums(other); if (!merged_enums.empty()) @@ -154,14 +187,28 @@ class ABIMerger { // key_names/key_types may differ: template-detected tables have them // populated while attribute-only tables have empty arrays. Both are // valid representations of the same table — treat as compatible. - const auto compatible = [](const ojson& x, const ojson& y) { + // Optional-key tolerant: an ABI from another toolchain need not carry the Wire + // extensions (table_id, secondary_indexes) at all. + const auto field = [](const ojson& o, const char* k) { + static const ojson absent = ojson::null(); + return o.has_key(k) ? o[k] : absent; + }; + const auto compatible = [&](const char* k) { + const ojson x = field(a, k); + const ojson y = field(b, k); return x == y || x.empty() || y.empty(); }; return a["name"] == b["name"] && a["type"] == b["type"] && - a["index_type"] == b["index_type"] && - compatible(a["key_names"], b["key_names"]) && - compatible(a["key_types"], b["key_types"]); + field(a, "index_type") == field(b, "index_type") && + // table_id is where the row physically lives and each secondary index carries + // its own, so a difference in either is a different table -- not a merge. + // These were omitted while cdt-abidiff's tables_match compared them, leaving + // the differ and the merger disagreeing on table identity. + field(a, "table_id") == field(b, "table_id") && + compatible("key_names") && + compatible("key_types") && + compatible("secondary_indexes"); } static bool clause_is_same(ojson a, ojson b) { From c28fe9a741c05bed4933cdf1bf9a5b8dbaed7e2b Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 1 Sep 2026 13:25:32 -0500 Subject: [PATCH 13/26] chore: fix the merger's absent-key tolerance and guard whole-section reads Pre-push review found a blocker in the previous commit, plus the tolerance it relied on being unreachable. table_is_same's `compatible()` tested `.empty()` to mean "unspecified", but an absent key reads as jsoncons null and `null.empty()` is FALSE (verified: null.empty()=0, array.empty()=1, null == array false). abigen writes secondary_indexes only when non-empty, so a translation unit that sees a table's [[sysio::table]] but not its indexed instantiation omits the key -- and that descriptor then refused to merge with the one that has it: a.cpp.desc: key_names ["id"], table_id 25830, secondary_indexes [byowner] b.cpp.desc: key_names [], table_id 25830 (secondary_indexes absent) before: exit 255, "Error, ABI structs malformed : mytbl already defined" after : exit 0, merged to the enriched table, in either descriptor order That is a multi-file contract master builds and this branch did not. Nothing caught it because the merger's table path had NO coverage at all -- every merge fixture used "tables":[] -- so ctest was 31/31 green with the regression present. Four assertions cover it now, and reverting the is_null() clause fails exactly the two that should. The "prefer richer" tiebreak inspected only key_names, so two descriptors agreeing on key_names but differing in whether they carried secondary_indexes merged order-dependently: rich-then-poor kept the indexes, poor-then-rich dropped them, decided by sorted .desc filename. Every optional list decides now. cdt-abidiff: field_or_null covered element fields but every top-level section read was still unguarded, so an ABI omitting `variants` or `action_results` -- which a stock Antelope 1.0/1.1 ABI routinely does -- aborted with exit 255, even against a byte-identical copy. All seven sections read through section_or_empty. Related: an absent scalar read as null and null != "", so an upstream ABI omitting an empty `base` reported a spurious struct difference; absent and blank now compare equal. abidiff_tests 36 assertions, abi_version_tests 49, ctest 31/31. --- tests/unit/abi_version_tests.sh | 44 +++++++++++++++ tests/unit/abidiff_tests.sh | 37 +++++++++++++ tools/abidiff/cdt-abidiff.cpp.in | 95 +++++++++++++++++++------------- tools/include/sysio/abimerge.hpp | 25 +++++++-- 4 files changed, 158 insertions(+), 43 deletions(-) diff --git a/tests/unit/abi_version_tests.sh b/tests/unit/abi_version_tests.sh index e06178d41..8afac17c5 100755 --- a/tests/unit/abi_version_tests.sh +++ b/tests/unit/abi_version_tests.sh @@ -345,6 +345,50 @@ else sed 's/^/ /' <<< "$(head -3 "${promo_dir}/v.abi")" fi +# --- merger: tables ------------------------------------------------------------------ +# +# The merger's table path had NO coverage at all -- every merge fixture above uses +# "tables":[] -- so a regression there was invisible to ctest. +# +# The case that matters is a multi-file contract. abigen writes `secondary_indexes` only when +# non-empty and `key_names` only when the indexed instantiation is visible, so a translation +# unit that sees a table's [[sysio::table]] but not its `kv::index` instantiation emits a +# descriptor with those keys absent or empty. That must merge with the richer one, in either +# order, and keep the richer metadata. It must NOT depend on which .desc sorts first. +TBL_COMMON='"types":[],"actions":[],"ricardian_clauses":[],"variants":[],"abi_extensions":[],"pb_types":[],"wasm_actions":[],"wasm_entries":[],"wasm_notifies":[],"action_results":[],"structs":[]' + +cat > "${WORK}/t_rich.desc" < "${WORK}/t_poor.desc" < "${dir}/mix.log" 2>&1; then + check "${label}: keeps key_names" "${dir}/mix.abi" '"id"' + check "${label}: keeps secondary_indexes" "${dir}/mix.abi" '"byowner"' + else + fail "${label}: descriptors merge" + sed 's/^/ /' "${dir}/mix.log" + fi +} +merge_table_case "partial table desc, rich first" "${WORK}/t_rich.desc" "${WORK}/t_poor.desc" +merge_table_case "partial table desc, poor first" "${WORK}/t_poor.desc" "${WORK}/t_rich.desc" + +# A genuinely different table_id is a different table, not a merge -- in both orders. +sed 's/25830/40000/' "${WORK}/t_rich.desc" > "${WORK}/t_otherid.desc" +merge_refuses_both_orders "tables with different table_ids conflict" \ + "${WORK}/t_rich.desc" "${WORK}/t_otherid.desc" + mkdesc_variant "${WORK}/v_short.desc" '["uint64"]' mkdesc_variant "${WORK}/v_long.desc" '["uint64","string"]' merge_refuses_both_orders "variants of differing length conflict" \ diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index 42d4b12b3..7c68e9214 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -397,6 +397,43 @@ expect_reports "a changed protobuf descriptor is reported" \ expect_quiet "an identical protobuf descriptor reports no difference" \ "${WORK}/pb_a.abi" "${WORK}/pb_a.abi" "protobuf_types" +# A stock Antelope 1.0/1.1 ABI omits `variants` and `action_results` entirely, and any absent +# top-level section used to abort the tool with "Key 'x' not found" (exit 255) -- including on +# two byte-identical files. capture() fails a case whose process exits non-zero, so these +# assert the comparison runs at all. +cat > "${WORK}/upstream.abi" <<'EOF' +{ + "version": "eosio::abi/1.0", + "types": [], "structs": [], "actions": [], "tables": [], "ricardian_clauses": [] +} +EOF +expect_quiet "an ABI missing whole sections diffs cleanly against itself" \ + "${WORK}/upstream.abi" "${WORK}/upstream.abi" "." + +cat > "${WORK}/upstream2.abi" <<'EOF' +{ + "version": "eosio::abi/1.0", + "types": [], "structs": [], "tables": [], "ricardian_clauses": [], + "actions": [ { "name": "act", "type": "act", "ricardian_contract": "" } ] +} +EOF +expect_reports "an ABI missing whole sections still reports a real difference" \ + "${WORK}/upstream2.abi" "${WORK}/upstream.abi" "action" + +# An upstream ABI omits an empty `base` rather than writing ""; absent and "" mean the same +# thing, so that must not read as a difference. +cat > "${WORK}/s_blank_a.abi" <<'EOF' +{ + "version": "sysio::abi/1.2", + "types": [], "actions": [], "tables": [], "ricardian_clauses": [], "variants": [], + "action_results": [], + "structs": [ { "name": "s", "fields": [ {"name":"a","type":"uint64"} ] } ] +} +EOF +sed 's/"name": "s",/"name": "s", "base": "",/' "${WORK}/s_blank_a.abi" > "${WORK}/s_blank_b.abi" +expect_quiet "an omitted base and an empty base are the same struct" \ + "${WORK}/s_blank_a.abi" "${WORK}/s_blank_b.abi" "struct" + echo "" echo "Results: ${PASS} passed, ${FAIL} failed" [ "$FAIL" -eq 0 ] diff --git a/tools/abidiff/cdt-abidiff.cpp.in b/tools/abidiff/cdt-abidiff.cpp.in index 6c4bb7f61..d1364ab3d 100644 --- a/tools/abidiff/cdt-abidiff.cpp.in +++ b/tools/abidiff/cdt-abidiff.cpp.in @@ -87,13 +87,21 @@ class abidiff { void print_struct(const ojson& abi, int index, char direction) { std::cout << direction << " struct\n"; - std::cout << pretty_print(abi["structs"].at(index)) << "\n"; + std::cout << pretty_print(section_or_empty(abi, "structs").at(index)) << "\n"; } // An absent key read through the CONST operator[] throws, and ABIs legitimately omit // keys: table_id, index_type and secondary_indexes are Wire extensions, so a stock // Antelope ABI carries none of them. Every optional field is read through this, so // absent compares equal to absent and never equal to present. + // A whole section may be absent -- a stock Antelope 1.0/1.1 ABI omits `variants` and + // `action_results` -- so every top-level section is read through this. Absent reads as + // an empty array, which is what "this ABI declares none" means. + static const ojson& section_or_empty(const ojson& o, const char* key) { + static const ojson none = ojson::array(); + return o.count(key) ? o.at(key) : none; + } + static const ojson& field_or_null(const ojson& o, const char* key) { static const ojson absent = ojson::null(); return o.count(key) ? o.at(key) : absent; @@ -101,6 +109,15 @@ class abidiff { // Element-wise equality over a JSON array. Used for the ABI's list-valued fields, where // both length and order are significant -- order is serialization order. + // Absent and empty mean the same thing for these scalars, so an ABI that omits an + // empty `base` or `index_type` is not reported as differing from one that writes "". + static bool scalars_equal(const ojson& a, const ojson& b) { + const auto blank = [](const ojson& v) { + return v.is_null() || (v.is_string() && v.as().empty()); + }; + return a == b || (blank(a) && blank(b)); + } + static bool arrays_equal(const ojson& a, const ojson& b) { if (a.size() != b.size()) return false; @@ -119,7 +136,7 @@ class abidiff { // field but the first left the flag set from the previous iteration and the changed // struct was reported as unchanged. static bool structs_match(const ojson& a, const ojson& b) { - if (field_or_null(a, "base") != field_or_null(b, "base")) + if (!scalars_equal(field_or_null(a, "base"), field_or_null(b, "base"))) return false; const auto& fa = field_or_null(a, "fields"); const auto& fb = field_or_null(b, "fields"); @@ -132,12 +149,12 @@ class abidiff { } void find_structs(const ojson& abi1, const ojson& abi2, char direction) { - for ( int i=0; i < abi1["structs"].size(); i++ ) { + for ( int i=0; i < section_or_empty(abi1, "structs").size(); i++ ) { bool found = false; - for ( int j=0; j < abi2["structs"].size(); j++ ) { - if (abi1["structs"].at(i)["name"] != abi2["structs"].at(j)["name"]) + for ( int j=0; j < section_or_empty(abi2, "structs").size(); j++ ) { + if (section_or_empty(abi1, "structs").at(i)["name"] != section_or_empty(abi2, "structs").at(j)["name"]) continue; - found = structs_match(abi1["structs"].at(i), abi2["structs"].at(j)); + found = structs_match(section_or_empty(abi1, "structs").at(i), section_or_empty(abi2, "structs").at(j)); break; // the name matched; that entry alone decides } if (!found) @@ -147,16 +164,16 @@ class abidiff { void print_type(const ojson& abi, int index, char direction) { std::cout << direction << " type\n"; - std::cout << pretty_print(abi["types"].at(index)) << "\n"; + std::cout << pretty_print(section_or_empty(abi, "types").at(index)) << "\n"; } void find_types(const ojson& abi1, const ojson& abi2, char direction) { - for ( int i=0; i < abi1["types"].size(); i++ ) { + for ( int i=0; i < section_or_empty(abi1, "types").size(); i++ ) { bool found = false; - for ( int j=0; j < abi2["types"].size(); j++ ) { - if (abi1["types"].at(i)["new_type_name"] != abi2["types"].at(j)["new_type_name"]) + for ( int j=0; j < section_or_empty(abi2, "types").size(); j++ ) { + if (section_or_empty(abi1, "types").at(i)["new_type_name"] != section_or_empty(abi2, "types").at(j)["new_type_name"]) continue; - if (field_or_null(abi1["types"].at(i), "type") != field_or_null(abi2["types"].at(j), "type")) + if (field_or_null(section_or_empty(abi1, "types").at(i), "type") != field_or_null(section_or_empty(abi2, "types").at(j), "type")) break; found = true; break; @@ -168,18 +185,18 @@ class abidiff { void print_action(const ojson& abi, int index, char direction) { std::cout << direction << " action\n"; - std::cout << pretty_print(abi["actions"].at(index)) << "\n"; + std::cout << pretty_print(section_or_empty(abi, "actions").at(index)) << "\n"; } void find_actions(const ojson& abi1, const ojson& abi2, char direction) { - for ( int i=0; i < abi1["actions"].size(); i++ ) { + for ( int i=0; i < section_or_empty(abi1, "actions").size(); i++ ) { bool found = false; - for ( int j=0; j < abi2["actions"].size(); j++ ) { - if (abi1["actions"].at(i)["name"] != abi2["actions"].at(j)["name"]) + for ( int j=0; j < section_or_empty(abi2, "actions").size(); j++ ) { + if (section_or_empty(abi1, "actions").at(i)["name"] != section_or_empty(abi2, "actions").at(j)["name"]) continue; - if (field_or_null(abi1["actions"].at(i), "type") != field_or_null(abi2["actions"].at(j), "type")) + if (field_or_null(section_or_empty(abi1, "actions").at(i), "type") != field_or_null(section_or_empty(abi2, "actions").at(j), "type")) break; - if (field_or_null(abi1["actions"].at(i), "ricardian_contract") != field_or_null(abi2["actions"].at(j), "ricardian_contract")) + if (field_or_null(section_or_empty(abi1, "actions").at(i), "ricardian_contract") != field_or_null(section_or_empty(abi2, "actions").at(j), "ricardian_contract")) break; found = true; break; @@ -191,7 +208,7 @@ class abidiff { void print_table(const ojson& abi, int index, char direction) { std::cout << direction << " table\n"; - std::cout << pretty_print(abi["tables"].at(index)) << "\n"; + std::cout << pretty_print(section_or_empty(abi, "tables").at(index)) << "\n"; } // Every field a table entry can carry. Comparing only name and type -- as this did -- @@ -204,7 +221,7 @@ class abidiff { // compared, not aborted on. static bool tables_match(const ojson& a, const ojson& b) { for (const char* key : {"type", "index_type", "table_id"}) - if (field_or_null(a, key) != field_or_null(b, key)) + if (!scalars_equal(field_or_null(a, key), field_or_null(b, key))) return false; for (const char* key : {"key_names", "key_types", "secondary_indexes"}) if (!arrays_equal(field_or_null(a, key), field_or_null(b, key))) @@ -213,12 +230,12 @@ class abidiff { } void find_tables(const ojson& abi1, const ojson& abi2, char direction) { - for ( int i=0; i < abi1["tables"].size(); i++ ) { + for ( int i=0; i < section_or_empty(abi1, "tables").size(); i++ ) { bool found = false; - for ( int j=0; j < abi2["tables"].size(); j++ ) { - if (abi1["tables"].at(i)["name"] != abi2["tables"].at(j)["name"]) + for ( int j=0; j < section_or_empty(abi2, "tables").size(); j++ ) { + if (section_or_empty(abi1, "tables").at(i)["name"] != section_or_empty(abi2, "tables").at(j)["name"]) continue; - found = tables_match(abi1["tables"].at(i), abi2["tables"].at(j)); + found = tables_match(section_or_empty(abi1, "tables").at(i), section_or_empty(abi2, "tables").at(j)); break; } if (!found) @@ -228,16 +245,16 @@ class abidiff { void print_clause(const ojson& abi, int index, char direction) { std::cout << direction << " clause\n"; - std::cout << pretty_print(abi["ricardian_clauses"].at(index)) << "\n"; + std::cout << pretty_print(section_or_empty(abi, "ricardian_clauses").at(index)) << "\n"; } void find_clauses(const ojson& abi1, const ojson& abi2, char direction) { - for ( int i=0; i < abi1["ricardian_clauses"].size(); i++ ) { + for ( int i=0; i < section_or_empty(abi1, "ricardian_clauses").size(); i++ ) { bool found = false; - for ( int j=0; j < abi2["ricardian_clauses"].size(); j++ ) { - if (abi1["ricardian_clauses"].at(i)["id"] != abi2["ricardian_clauses"].at(j)["id"]) + for ( int j=0; j < section_or_empty(abi2, "ricardian_clauses").size(); j++ ) { + if (section_or_empty(abi1, "ricardian_clauses").at(i)["id"] != section_or_empty(abi2, "ricardian_clauses").at(j)["id"]) continue; - if (field_or_null(abi1["ricardian_clauses"].at(i), "body") != field_or_null(abi2["ricardian_clauses"].at(j), "body")) + if (field_or_null(section_or_empty(abi1, "ricardian_clauses").at(i), "body") != field_or_null(section_or_empty(abi2, "ricardian_clauses").at(j), "body")) break; found = true; break; @@ -249,21 +266,21 @@ class abidiff { void print_variant(const ojson& abi, int index, char direction) { std::cout << direction << " variant\n"; - std::cout << pretty_print(abi["variants"].at(index)) << "\n"; + std::cout << pretty_print(section_or_empty(abi, "variants").at(index)) << "\n"; } void find_variants(const ojson& abi1, const ojson& abi2, char direction) { - for ( int i=0; i < abi1["variants"].size(); i++ ) { + for ( int i=0; i < section_or_empty(abi1, "variants").size(); i++ ) { bool found = false; - for ( int j=0; j < abi2["variants"].size(); j++ ) { - if (abi1["variants"].at(i)["name"] != abi2["variants"].at(j)["name"]) + for ( int j=0; j < section_or_empty(abi2, "variants").size(); j++ ) { + if (section_or_empty(abi1, "variants").at(i)["name"] != section_or_empty(abi2, "variants").at(j)["name"]) continue; // The whole type list, length and order. The original broke out of the element // loop on a mismatch and then set found unconditionally, so a same-named variant // counted as unchanged however its types differed; with no length check, at(k) // also threw on a shorter right-hand side. - found = arrays_equal(field_or_null(abi1["variants"].at(i), "types"), - field_or_null(abi2["variants"].at(j), "types")); + found = arrays_equal(field_or_null(section_or_empty(abi1, "variants").at(i), "types"), + field_or_null(section_or_empty(abi2, "variants").at(j), "types")); break; } if (!found) @@ -273,16 +290,16 @@ class abidiff { void print_action_results(const ojson& abi, int index, char direction) { std::cout << direction << " action_result\n"; - std::cout << pretty_print(abi["action_results"].at(index)) << "\n"; + std::cout << pretty_print(section_or_empty(abi, "action_results").at(index)) << "\n"; } void find_action_results(const ojson& abi1, const ojson& abi2, char direction) { - for ( int i=0; i < abi1["action_results"].size(); i++ ) { + for ( int i=0; i < section_or_empty(abi1, "action_results").size(); i++ ) { bool found = false; - for ( int j=0; j < abi2["action_results"].size(); j++ ) { - if (abi1["action_results"].at(i)["name"] != abi2["action_results"].at(j)["name"]) + for ( int j=0; j < section_or_empty(abi2, "action_results").size(); j++ ) { + if (section_or_empty(abi1, "action_results").at(i)["name"] != section_or_empty(abi2, "action_results").at(j)["name"]) continue; - if (field_or_null(abi1["action_results"].at(i), "result_type") != field_or_null(abi2["action_results"].at(j), "result_type")) + if (field_or_null(section_or_empty(abi1, "action_results").at(i), "result_type") != field_or_null(section_or_empty(abi2, "action_results").at(j), "result_type")) break; found = true; break; diff --git a/tools/include/sysio/abimerge.hpp b/tools/include/sysio/abimerge.hpp index 5ffa8102b..edd98d188 100644 --- a/tools/include/sysio/abimerge.hpp +++ b/tools/include/sysio/abimerge.hpp @@ -193,10 +193,18 @@ class ABIMerger { static const ojson absent = ojson::null(); return o.has_key(k) ? o[k] : absent; }; + // "Unspecified" is either an ABSENT key or an empty array, and the two are not + // interchangeable in jsoncons: an absent key reads as null, and null.empty() is + // FALSE while array.empty() is true, so testing empty() alone never fired for a + // missing key. abigen writes secondary_indexes only when non-empty, so a + // translation unit that sees a table's [[sysio::table]] but not its indexed + // instantiation omits the key entirely -- and that TU's descriptor then failed to + // merge with the one that has it, breaking multi-file contracts that master builds. + const auto unspecified = [](const ojson& v) { return v.is_null() || v.empty(); }; const auto compatible = [&](const char* k) { const ojson x = field(a, k); const ojson y = field(b, k); - return x == y || x.empty() || y.empty(); + return x == y || unspecified(x) || unspecified(y); }; return a["name"] == b["name"] && a["type"] == b["type"] && @@ -269,9 +277,18 @@ class ABIMerger { if (!is_same_func(ret[i], obj_b)) { throw std::runtime_error(std::string("Error, ABI structs malformed : ")+ret[i][id].as()+" already defined"); } - // Prefer the entry with richer key metadata (non-empty key_names) - if (ret[i].count("key_names") && obj_b.count("key_names") && - ret[i]["key_names"].empty() && !obj_b["key_names"].empty()) { + // Prefer the richer entry. Checking only key_names left the outcome + // order-dependent whenever two descriptors agreed on key_names but + // differed in whether they carried secondary_indexes: rich-then-poor kept + // the indexes, poor-then-rich dropped them, decided by sorted .desc + // filename. Every optional list decides, not just the first one. + const auto richer_in = [&](const char* k) { + const bool have_a = ret[i].count(k) && !ret[i][k].empty(); + const bool have_b = obj_b.count(k) && !obj_b[k].empty(); + return !have_a && have_b; + }; + if (richer_in("key_names") || richer_in("key_types") || + richer_in("secondary_indexes")) { ret[i] = obj_b; } should_skip = true; From 995de046e4410f2d0b3da9ece83defcf82aaa9c3 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 1 Sep 2026 14:26:22 -0500 Subject: [PATCH 14/26] chore: merge table metadata per key, and correct a false claim about master Final review round. The code was sound but two things were not. The "prefer richer" tiebreak replaced the accumulator entry WHOLESALE on any of three optional lists, discarding whichever list the accumulator was richer in. Two descriptors each rich in a different key therefore produced a different result depending on which .desc sorted first -- reintroducing, in the code whose own comment claims to remove it, the determinism hazard this work exists to fix: keys-rich then idx-rich : key_names LOST idx-rich then keys-rich : secondary_indexes LOST Merging per key gives the union in either order. Latent rather than live -- today's abigen cannot emit that shape, since the branch that leaves key_names empty is only reached by singleton, which never carries secondary indexes -- so no artifact changes. Four assertions pin it; restoring the wholesale replace fails exactly two. The comment justifying the version promotion said master emitted a document "that references a type it does not define". It does not: master's ret["variants"] is unconditional (abimerge.hpp:50 on origin/master), so the array is present and nothing dangles. Master's actual defect is smaller -- a 1.0 stamp on a document carrying a section the format introduced at 1.1. Gating the section instead of promoting the stamp is what would have made it lossy, which is why promotion is the right answer, but the reason was stated wrong. Corrected in abimerge.hpp, in the test comment, and in the PR body. Also: the two fixtures added last round for section_or_empty used eosio::abi/1.0, whose version gates diff_variants/diff_action_results off -- so the sections they omit were never read and the fixtures could not fail. Moved to 1.2, where both are read. And was left behind by the same commit that removed the file's only assert(). abidiff_tests 36, abi_version_tests 53, ctest 31/31. --- tests/unit/abi_version_tests.sh | 25 +++++++++++++++---- tests/unit/abidiff_tests.sh | 7 ++++-- tools/codegen/cdt-codegen.cpp | 1 - tools/include/sysio/abimerge.hpp | 41 ++++++++++++++++++-------------- 4 files changed, 48 insertions(+), 26 deletions(-) diff --git a/tests/unit/abi_version_tests.sh b/tests/unit/abi_version_tests.sh index 8afac17c5..2e839da09 100755 --- a/tests/unit/abi_version_tests.sh +++ b/tests/unit/abi_version_tests.sh @@ -308,11 +308,13 @@ merge_refuses_both_orders() { done } -# A populated gated section PROMOTES the emitted version rather than being dropped. abigen -# writes `variants` at every version, so gating the merge output on the requested version -# discarded the array while the struct field still referenced `variant_uint64_string` -- an -# ABI naming a type it does not define. Promotion is what the protobuf path already does. -# Asserted end to end, since the interesting part is abigen and the merger agreeing. +# A populated gated section PROMOTES the emitted version rather than being dropped. Master +# emitted `variants` unconditionally, so a 1.0 build produced a document stamped 1.0 that +# carried a section the format introduced at 1.1 -- inconsistent, not lossy. Gating the section +# on the requested version would have made it lossy instead, since the struct field keeps +# referencing `variant_uint64_string` after the array defining it is dropped. Promoting the +# stamp is the only option that is neither. Asserted end to end, because the interesting part +# is abigen and the merger agreeing. promo_dir="${WORK}/promote_1_0"; mkdir -p "$promo_dir" cat > "${promo_dir}/v.cpp" <<'EOF' #include @@ -384,6 +386,19 @@ merge_table_case() { # $1=label $2=firstdesc $3=seconddesc merge_table_case "partial table desc, rich first" "${WORK}/t_rich.desc" "${WORK}/t_poor.desc" merge_table_case "partial table desc, poor first" "${WORK}/t_poor.desc" "${WORK}/t_rich.desc" +# Split richness: each descriptor carries one optional list the other lacks. Replacing the +# accumulator wholesale -- as an earlier revision did on any of the three keys -- discarded +# whichever list the accumulator was richer in, so the result depended on which .desc sorted +# first. Merging per key gives the union either way. +cat > "${WORK}/t_keys_only.desc" < "${WORK}/t_idx_only.desc" < "${WORK}/t_otherid.desc" merge_refuses_both_orders "tables with different table_ids conflict" \ diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index 7c68e9214..b71693021 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -401,9 +401,12 @@ expect_quiet "an identical protobuf descriptor reports no difference" \ # top-level section used to abort the tool with "Key 'x' not found" (exit 255) -- including on # two byte-identical files. capture() fails a case whose process exits non-zero, so these # assert the comparison runs at all. +# Version 1.2, deliberately: at 1.0 the version gates diff_variants/diff_action_results off, so +# an ABI omitting those sections is never read and the fixture cannot fail. At 1.2 both are +# read, so this pins section_or_empty rather than merely exercising the happy path. cat > "${WORK}/upstream.abi" <<'EOF' { - "version": "eosio::abi/1.0", + "version": "eosio::abi/1.2", "types": [], "structs": [], "actions": [], "tables": [], "ricardian_clauses": [] } EOF @@ -412,7 +415,7 @@ expect_quiet "an ABI missing whole sections diffs cleanly against itself" \ cat > "${WORK}/upstream2.abi" <<'EOF' { - "version": "eosio::abi/1.0", + "version": "eosio::abi/1.2", "types": [], "structs": [], "tables": [], "ricardian_clauses": [], "actions": [ { "name": "act", "type": "act", "ricardian_contract": "" } ] } diff --git a/tools/codegen/cdt-codegen.cpp b/tools/codegen/cdt-codegen.cpp index a9f87f684..1299962ce 100644 --- a/tools/codegen/cdt-codegen.cpp +++ b/tools/codegen/cdt-codegen.cpp @@ -1,4 +1,3 @@ -#include #include #include #include diff --git a/tools/include/sysio/abimerge.hpp b/tools/include/sysio/abimerge.hpp index edd98d188..4306f58bf 100644 --- a/tools/include/sysio/abimerge.hpp +++ b/tools/include/sysio/abimerge.hpp @@ -66,13 +66,15 @@ class ABIMerger { ret["ricardian_clauses"] = merge_clauses(other); // A section belongs to the emitted document if it has content, and the emitted - // VERSION is then raised to one that admits it. Gating the other way -- dropping a - // populated section because the requested version predates it -- emits a document - // that references a type it does not define: abigen writes `variants` - // unconditionally, so a contract with a std::variant parameter built at - // -abi-version 1.0 had its struct field still typed `variant_uint64_string` while - // the variants array itself was silently discarded. Promoting is what the protobuf - // path already does when it moves a document to 1.3. + // VERSION is then raised to one that admits it. + // + // Master emitted `variants` unconditionally, so a contract with a std::variant + // parameter built at -abi-version 1.0 got a document stamped 1.0 that nonetheless + // carried a section the format introduced at 1.1 -- self-inconsistent, though not + // lossy. Simply gating the section on the requested version would have made it + // lossy: the struct field stays typed `variant_uint64_string` while the array + // defining it disappears. Promoting the stamp keeps the document complete AND + // consistent, and is what the protobuf path already does when it moves to 1.3. // Promote FIRST, from every gated section, then emit -- so the outcome does not // depend on the order the sections are considered in. (Emitting as we go meant a // populated action_results could raise the version to 1.2 after an empty variants @@ -277,19 +279,22 @@ class ABIMerger { if (!is_same_func(ret[i], obj_b)) { throw std::runtime_error(std::string("Error, ABI structs malformed : ")+ret[i][id].as()+" already defined"); } - // Prefer the richer entry. Checking only key_names left the outcome - // order-dependent whenever two descriptors agreed on key_names but - // differed in whether they carried secondary_indexes: rich-then-poor kept - // the indexes, poor-then-rich dropped them, decided by sorted .desc - // filename. Every optional list decides, not just the first one. - const auto richer_in = [&](const char* k) { + // Take the richer value for EACH optional list independently, rather than + // replacing the whole entry. Two earlier forms were both order-dependent: + // checking only key_names dropped a secondary_indexes the other side + // carried, and replacing wholesale on any of the three discarded whichever + // list the accumulator was richer in -- so two descriptors each rich in a + // different key produced a different result depending on which .desc + // sorted first. Per-key, the union is the same either way. + // + // is_same_func has already established these describe the same entity, so + // there is no conflict to resolve here: a populated list only ever fills + // in for an absent or empty one. + for (const char* k : {"key_names", "key_types", "secondary_indexes"}) { const bool have_a = ret[i].count(k) && !ret[i][k].empty(); const bool have_b = obj_b.count(k) && !obj_b[k].empty(); - return !have_a && have_b; - }; - if (richer_in("key_names") || richer_in("key_types") || - richer_in("secondary_indexes")) { - ret[i] = obj_b; + if (!have_a && have_b) + ret[i][k] = obj_b[k]; } should_skip = true; } From edabb09cd0a53ae4118f8a54b6bb2959981003d9 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Wed, 2 Sep 2026 10:45:49 -0500 Subject: [PATCH 15/26] chore: stop pruning libsf.a, and diff sections the version gate was hiding Seven review findings, two of them P1. libsf.a is a WebAssembly archive, not a native-host one -- cdt-ld links it with -lsf for --use-rt and every --fquery mode -- and it is only declared under libraries/native/, which is why an OFF configure never rebuilds it. Pruning it there stripped a working copy from a reused tree and left an OFF package unable to link those modes. The prune covers libnative* only, and the isolated probe now asserts libsf.a SURVIVES rather than that it is removed. cdt-abidiff's variant and action-result diffs were gated on the declared version, which suppressed real content: two 1.0 documents whose same-named variant changed from ["uint64"] to ["string"] reported nothing, as did populated action_results below 1.2. A version stamp says which sections a document must CARRY, not which it may contain -- abigen emits variants at every version. Both are compared unconditionally now; absent sections read as empty rather than throwing, which is what made the gate removable. The absent-is-empty fallback was also reaching REQUIRED sections, so an ABI missing `actions` compared equal to one with "actions": []. Master threw on the missing key. The five schema-required sections are validated up front and a truncated document is refused with a diagnostic. error_messages and abi_extensions were compared by nothing; both are abi_def fields and a change in either is an interface change. abi.hpp derived the section-introduction versions from max_supported_major, which is the highest major ACCEPTED, not the version a section was introduced in. Raising it to 2 would have made supports_variants(1,10) false while parse() still accepted major 1 -- letting valid 1.x documents omit required sections. variants_since and action_results_since are now explicit (1,1) and (1,2) pairs, compared as (major, minor). Two test gaps: - the merge helper checked key_names and secondary_indexes but not key_types, so dropping key_types from the per-key loop left every case green. Now pinned; dropping it fails two cases. - staged_headers_tests proved only an aggregate count, which cannot show that each tree was staged -- deleting the bluegrass copy left it green on the strength of the other five. It asserts presence per destination and plants a sentinel in each, in an ISOLATED scratch tree: an earlier draft did this against the live include dir and raced toolchain_tests under ctest -j. abidiff_tests 42, abi_version_tests 57, staged_headers 12. ctest 31/31 twice in parallel; abigen-pass 16/16 unchanged. --- cmake/stage_cdt_tree.cmake | 10 ++++- tests/unit/abi_version_tests.sh | 4 ++ tests/unit/abidiff_tests.sh | 55 +++++++++++++++++++++++ tests/unit/staged_headers_tests.sh | 70 +++++++++++++++++++++++++++++- tools/abidiff/cdt-abidiff.cpp.in | 53 +++++++++++++++++----- tools/include/sysio/abi.hpp | 16 +++++-- tools/include/sysio/abimerge.hpp | 12 ++--- 7 files changed, 196 insertions(+), 24 deletions(-) diff --git a/cmake/stage_cdt_tree.cmake b/cmake/stage_cdt_tree.cmake index de45f76ec..d3cd68e06 100644 --- a/cmake/stage_cdt_tree.cmake +++ b/cmake/stage_cdt_tree.cmake @@ -56,13 +56,19 @@ file(COPY "${STAGE_SOURCE_DIR}/sysiolib" DESTINATION "${STAGE_BINARY_DIR}/include" ${header_patterns}) -# The native archives are copied into lib/ by POST_BUILD commands that exist only while +# The native-host archives are copied into lib/ by POST_BUILD commands that exist only while # ENABLE_NATIVE_COMPILER is on. Reconfiguring a reused tree to OFF removes those targets but # not the files they already copied, and InstallCDT.cmake installs lib/ wholesale -- so an OFF # build packaged archives its own configuration never produced, still carrying whatever symbols # the last ON build put in them. +# +# libnative* ONLY. libsf.a is a WebAssembly archive, not a native-host one: cdt-ld links it +# with -lsf for --use-rt and every --fquery mode (compiler_options.hpp.in), and the base +# install ships it. It merely happens to be declared in libraries/native/CMakeLists.txt, which +# is why an OFF configure does not rebuild it -- so deleting it here would strip a working +# copy from a reused tree and leave an OFF package unable to link those modes. if(NOT STAGE_NATIVE) - file(GLOB stale_native "${STAGE_BINARY_DIR}/lib/libnative*" "${STAGE_BINARY_DIR}/lib/libsf.a") + file(GLOB stale_native "${STAGE_BINARY_DIR}/lib/libnative*") if(stale_native) file(REMOVE ${stale_native}) endif() diff --git a/tests/unit/abi_version_tests.sh b/tests/unit/abi_version_tests.sh index 2e839da09..1d6b5690c 100755 --- a/tests/unit/abi_version_tests.sh +++ b/tests/unit/abi_version_tests.sh @@ -376,7 +376,11 @@ merge_table_case() { # $1=label $2=firstdesc $3=seconddesc --abi-output "${dir}/mix.abi" \ --desc-file "${dir}/a_first.desc" --desc-file "${dir}/b_second.desc" \ > "${dir}/mix.log" 2>&1; then + # Each of the three lists is copied independently, so each needs its own assertion. + # Checking only key_names and secondary_indexes left key_types pinned by nothing: + # dropping it from the merge loop kept every case green while "uint64" vanished. check "${label}: keeps key_names" "${dir}/mix.abi" '"id"' + check "${label}: keeps key_types" "${dir}/mix.abi" '"uint64"' check "${label}: keeps secondary_indexes" "${dir}/mix.abi" '"byowner"' else fail "${label}: descriptors merge" diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index b71693021..a71578d8a 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -437,6 +437,61 @@ sed 's/"name": "s",/"name": "s", "base": "",/' "${WORK}/s_blank_a.abi" > "${WORK expect_quiet "an omitted base and an empty base are the same struct" \ "${WORK}/s_blank_a.abi" "${WORK}/s_blank_b.abi" "struct" +# --- legacy versions --------------------------------------------------------------------- +# +# The variant and action-result diffs were gated on the declared version, which suppressed +# real content: abigen emits `variants` at every version, so two 1.0 documents whose variant +# changed reported nothing. A version stamp says which sections a document must CARRY, not +# which it may contain. +mk_legacy() { # $1=path $2=version $3=extra-json + cat > "$1" <&1)"; then + fail "an ABI missing a required section is refused" + sed 's/^/ /' <<< "$out" +elif grep -q "missing the required ABI section" <<< "$out"; then + pass "an ABI missing a required section is refused" +else + fail "an ABI missing a required section is refused" + sed 's/^/ /' <<< "$out" +fi + +# --- remaining payload sections ------------------------------------------------------------ +mk_legacy "${WORK}/em1.abi" "sysio::abi/1.2" '"error_messages": []' +mk_legacy "${WORK}/em2.abi" "sysio::abi/1.2" '"error_messages": [ { "error_code": 1, "error_msg": "boom" } ]' +expect_reports "a changed error_messages is reported" "${WORK}/em1.abi" "${WORK}/em2.abi" "error_messages" +expect_quiet "an identical error_messages reports no difference" \ + "${WORK}/em1.abi" "${WORK}/em1.abi" "error_messages" + +mk_legacy "${WORK}/ax1.abi" "sysio::abi/1.2" '"abi_extensions": []' +mk_legacy "${WORK}/ax2.abi" "sysio::abi/1.2" '"abi_extensions": [ [ 1, "00" ] ]' +expect_reports "a changed abi_extensions is reported" "${WORK}/ax1.abi" "${WORK}/ax2.abi" "abi_extensions" + echo "" echo "Results: ${PASS} passed, ${FAIL} failed" [ "$FAIL" -eq 0 ] diff --git a/tests/unit/staged_headers_tests.sh b/tests/unit/staged_headers_tests.sh index c55e37f2c..4ddd34568 100755 --- a/tests/unit/staged_headers_tests.sh +++ b/tests/unit/staged_headers_tests.sh @@ -98,6 +98,19 @@ else pass "found ${staged_count} staged CDT headers" fi +# Per destination, not just in aggregate. A single total cannot show that every tree was +# staged: dropping the bluegrass copy from stage_cdt_tree.cmake leaves the count nonzero on +# the strength of the other five, and the test stays green. +for dest in sysiolib libc libcxx boost/preprocessor bluegrass; do + n="$(find "${INCLUDE_DIR}/${dest}" -type f 2>/dev/null | wc -l)" + if [ "$n" -gt 0 ]; then + pass "${dest} is staged (${n} files)" + else + fail "${dest} is staged" + echo " nothing under ${INCLUDE_DIR}/${dest}" + fi +done + if [ "${#stale[@]}" -eq 0 ]; then pass "every staged header has a source counterpart" else @@ -107,6 +120,51 @@ else echo " stage_cdt_tree should have pruned these; see cmake/stage_cdt_tree.cmake" fi +# Pruning, per destination -- in an ISOLATED tree. An earlier version planted sentinels in the +# live ${INCLUDE_DIR} and re-ran the staging script there, which wipes and repopulates the very +# headers other tests are compiling against; under `ctest -j` that raced toolchain_tests and +# abi_version_tests. Staging into a scratch destination proves the same property and touches +# nothing shared. +if ! command -v cmake > /dev/null 2>&1; then + echo " SKIP: cmake not on PATH (per-tree prune)" +else + PRUNE_SCRATCH="$(mktemp -d)" + if ! cmake -DSTAGE_SOURCE_DIR="${SOURCE_DIR}/libraries" \ + -DSTAGE_BINARY_DIR="${PRUNE_SCRATCH}" \ + -DSTAGE_NATIVE="${NATIVE_ENABLED}" \ + -P "${SOURCE_DIR}/cmake/stage_cdt_tree.cmake" > "${PRUNE_SCRATCH}/stage.log" 2>&1; then + fail "the staging script populates a fresh tree" + sed 's/^/ /' "${PRUNE_SCRATCH}/stage.log" + else + planted=0 + for dest in sysiolib libc libcxx boost/preprocessor bluegrass; do + if [ -d "${PRUNE_SCRATCH}/include/${dest}" ]; then + : > "${PRUNE_SCRATCH}/include/${dest}/zz_stale_probe.hpp" && planted=$((planted + 1)) + else + fail "fresh staging created ${dest}" + fi + done + if [ "$planted" -ne 5 ]; then + fail "planted a stale sentinel in each staged tree (planted ${planted}, expected 5)" + elif ! cmake -DSTAGE_SOURCE_DIR="${SOURCE_DIR}/libraries" \ + -DSTAGE_BINARY_DIR="${PRUNE_SCRATCH}" \ + -DSTAGE_NATIVE="${NATIVE_ENABLED}" \ + -P "${SOURCE_DIR}/cmake/stage_cdt_tree.cmake" > /dev/null 2>&1; then + fail "the staging script re-runs cleanly" + else + survivors="$(find "${PRUNE_SCRATCH}/include" -name 'zz_stale_probe.hpp' 2>/dev/null | wc -l)" + if [ "$survivors" -eq 0 ]; then + pass "a stale file is pruned from every staged tree" + else + fail "a stale file is pruned from every staged tree" + find "${PRUNE_SCRATCH}/include" -name 'zz_stale_probe.hpp' \ + | sed "s|${PRUNE_SCRATCH}/include/| |" + fi + fi + fi + rm -rf "$PRUNE_SCRATCH" +fi + # With native mode off, the native headers must not be staged at all. They are pruned # unconditionally rather than inside the STAGE_NATIVE branch, because a build tree whose # ENABLE_NATIVE_COMPILER flipped ON -> OFF would otherwise keep the previous build's copy @@ -161,7 +219,7 @@ else -DSTAGE_NATIVE=0 -P "${SOURCE_DIR}/cmake/stage_cdt_tree.cmake" \ > "${SCRATCH}/stage.log" 2>&1; then leftovers=() - for f in "${SCRATCH}/lib/libnative.a" "${SCRATCH}/lib/libnative_sysio.a" "${SCRATCH}/lib/libsf.a" \ + for f in "${SCRATCH}/lib/libnative.a" "${SCRATCH}/lib/libnative_sysio.a" \ "${SCRATCH}/include/sysio/native" "${SCRATCH}/include/sysiolib/native"; do [ -e "$f" ] && leftovers+=("$f") done @@ -172,6 +230,16 @@ else for f in "${leftovers[@]}"; do echo " survived: $f"; done fi + # libsf.a must SURVIVE. It is the WebAssembly softfloat archive cdt-ld links with + # -lsf for --use-rt and the --fquery modes, not a native-host archive -- it is only + # declared under libraries/native/, which is why an OFF configure never rebuilds it. + # Pruning it would leave an OFF package unable to link those modes. + if [ -e "${SCRATCH}/lib/libsf.a" ]; then + pass "STAGE_NATIVE=0 keeps libsf.a (a wasm archive, not a native one)" + else + fail "STAGE_NATIVE=0 keeps libsf.a (a wasm archive, not a native one)" + fi + # An unrelated archive must be left alone -- the prune is targeted, not a wipe. if [ -e "${SCRATCH}/lib/libc.a" ]; then pass "the prune leaves unrelated archives alone" diff --git a/tools/abidiff/cdt-abidiff.cpp.in b/tools/abidiff/cdt-abidiff.cpp.in index d1364ab3d..b1720f1dd 100644 --- a/tools/abidiff/cdt-abidiff.cpp.in +++ b/tools/abidiff/cdt-abidiff.cpp.in @@ -94,14 +94,28 @@ class abidiff { // keys: table_id, index_type and secondary_indexes are Wire extensions, so a stock // Antelope ABI carries none of them. Every optional field is read through this, so // absent compares equal to absent and never equal to present. - // A whole section may be absent -- a stock Antelope 1.0/1.1 ABI omits `variants` and - // `action_results` -- so every top-level section is read through this. Absent reads as - // an empty array, which is what "this ABI declares none" means. + // A whole section may be legitimately absent -- a stock Antelope 1.0/1.1 ABI omits + // `variants` and `action_results` -- so those are read through this. Absent reads as an + // empty array, which is what "this ABI declares none" means. static const ojson& section_or_empty(const ojson& o, const char* key) { static const ojson none = ojson::array(); return o.count(key) ? o.at(key) : none; } + // The sections the ABI schema requires. Reading these through section_or_empty too + // would make a TRUNCATED document compare equal to a complete one whose section is + // empty -- so a missing `actions` key would diff clean against `"actions": []`. Master + // threw on the missing key; this reports it and exits non-zero instead. + static void require_sections(const ojson& abi, const char* which) { + for (const char* key : {"types", "structs", "actions", "tables", "ricardian_clauses"}) { + if (!abi.count(key)) { + std::cerr << "Error: " << which << " is missing the required ABI section '" + << key << "'\n"; + exit(1); + } + } + } + static const ojson& field_or_null(const ojson& o, const char* key) { static const ojson absent = ojson::null(); return o.count(key) ? o.at(key) : absent; @@ -387,7 +401,21 @@ class abidiff { } } + // error_messages and abi_extensions are the remaining abi_def payload sections. CDT + // does not emit either, but an ABI produced elsewhere can carry them, and a change + // there is a real interface change. Compared whole, like protobuf_types. + void diff_opaque_section(const char* key) { + const ojson& a = field_or_null(abi_1, key); + const ojson& b = field_or_null(abi_2, key); + if (a != b) { + std::cout << "< " << key << "\n" << pretty_print(a) << "\n"; + std::cout << "> " << key << "\n" << pretty_print(b) << "\n"; + } + } + void diff() { + require_sections(abi_1, "file1"); + require_sections(abi_2, "file2"); diff_version(); diff_structs(); diff_types(); @@ -396,14 +424,17 @@ class abidiff { diff_clauses(); diff_enums(); diff_protobuf_types(); - const auto [v1_major, v1_minor] = get_version(abi_1, "file1"); - const auto [v2_major, v2_minor] = get_version(abi_2, "file2"); - if ( abi_version::supports_variants(v1_major, v1_minor) && - abi_version::supports_variants(v2_major, v2_minor) ) - diff_variants(); - if ( abi_version::supports_action_results(v1_major, v1_minor) && - abi_version::supports_action_results(v2_major, v2_minor) ) - diff_action_results(); + // Unconditional. These were version-gated, which suppressed real differences: two + // 1.0 documents whose same-named variant changed from ["uint64"] to ["string"] + // reported nothing, and populated action_results below 1.2 likewise. A version + // stamp says which sections a document is REQUIRED to carry, not which ones it may + // contain -- abigen emits `variants` at every version -- so the gate answered the + // wrong question. Both sections are read through section_or_empty, so an input that + // genuinely omits them compares as empty rather than throwing. + diff_variants(); + diff_action_results(); + diff_opaque_section("error_messages"); + diff_opaque_section("abi_extensions"); } }; diff --git a/tools/include/sysio/abi.hpp b/tools/include/sysio/abi.hpp index 4d8048a16..b53565cd4 100644 --- a/tools/include/sysio/abi.hpp +++ b/tools/include/sysio/abi.hpp @@ -40,15 +40,22 @@ namespace abi_version { /// does not stays at the baseline. inline constexpr int protobuf_minor = 3; - /// The minor from which `variants` is part of the format. + /// The version at which `variants` entered the format. A FIXED point in the format's + /// history, unrelated to max_supported_major, which is only the highest major this + /// toolchain accepts. Deriving one from the other made raising the accepted maximum move + /// every introduction with it -- supports_variants(1, 10) would become false while parse() + /// still accepted major 1, letting valid 1.x documents omit sections they require. + inline constexpr int variants_major = 1; inline constexpr int variants_minor = 1; - /// The minor from which `action_results` is part of the format. + /// The version at which `action_results` entered the format. + inline constexpr int action_results_major = 1; inline constexpr int action_results_minor = 2; /// Does a version carry the `variants` section? inline constexpr bool supports_variants(int major_v, int minor_v) { - return major_v == max_supported_major && minor_v >= variants_minor; + return major_v > variants_major || + (major_v == variants_major && minor_v >= variants_minor); } /// Does a version carry the `action_results` section? @@ -58,7 +65,8 @@ namespace abi_version { /// whether to diff it. Three separate spellings of this rule is how a contract /// ends up with a version stamp that promises a section its ABI does not carry. inline constexpr bool supports_action_results(int major_v, int minor_v) { - return major_v == max_supported_major && minor_v >= action_results_minor; + return major_v > action_results_major || + (major_v == action_results_major && minor_v >= action_results_minor); } /// The full "sysio::abi/." string stamped into a contract's ABI. diff --git a/tools/include/sysio/abimerge.hpp b/tools/include/sysio/abimerge.hpp index 4306f58bf..6b2a01619 100644 --- a/tools/include/sysio/abimerge.hpp +++ b/tools/include/sysio/abimerge.hpp @@ -381,14 +381,14 @@ class ABIMerger { ojson abi; }; -// max_supported_major, not default_major: these say which version of the FORMAT introduced the -// section, which is a property of the format, not of what this toolchain happens to emit by -// default. The two are equal today, so bumping the emission default would silently move every -// threshold with it. +// The fixed versions at which each section entered the format -- not default_major (what this +// toolchain emits by default) and not max_supported_major (the highest major it accepts). +// Deriving them from either makes a change to that unrelated knob silently move every +// threshold. inline const ABIMerger::section_since ABIMerger::variants_since{ - std::pair{abi_version::max_supported_major, abi_version::variants_minor}}; + std::pair{abi_version::variants_major, abi_version::variants_minor}}; inline const ABIMerger::section_since ABIMerger::action_results_since{ - std::pair{abi_version::max_supported_major, abi_version::action_results_minor}}; + std::pair{abi_version::action_results_major, abi_version::action_results_minor}}; inline const ABIMerger::section_since ABIMerger::never_mandatory{}; #pragma GCC diagnostic pop From 9c35d1dd67c214223fd9e0484ae70250c3b3acca Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Wed, 2 Sep 2026 14:00:30 -0500 Subject: [PATCH 16/26] chore: build the wasm softfloat archive in every configuration Six review findings. Preserving a stale libsf.a was not enough -- a CLEAN native-disabled build produced none at all, because libraries/CMakeLists.txt skipped the whole native subdirectory and the only `sf` target lives there. cdt-ld still links -lsf for --use-rt and every --fquery mode, so that package could not link them. The directory is added unconditionally now and the native-HOST targets are gated inside it; `sf` and its staging are not. Verified with a clean ENABLE_NATIVE_COMPILER=OFF library build: libsf.a is produced and staged, its objects are "WebAssembly (wasm) binary module", and no libnative* appears. staged_headers_tests contradicted itself -- the live OFF branch listed libsf.a among forbidden leftovers while the isolated probe required the same file to survive. The OFF branch rejects libnative* only and now positively requires libsf.a. cdt-abidiff: - require_sections was stricter than the runtime. Every list member of abi_def is a vector<> that default-constructs empty, so the chain reads an omitted key and an explicit empty array identically; demanding five of them rejected schema-valid minimal documents. Removed, and omitted-vs-empty is asserted instead. - error_messages and abi_extensions are optional vectors, so an omitted section printed as `null` against an explicit `[]`. Both read through section_or_empty now. error_messages is also consumed into a map keyed by error_code, so it is matched by key rather than array position -- the same codes in a different order are the same ABI. - diff_version compared only the parsed components, so an otherwise identical eosio::abi/1.2 and sysio::abi/1.2 reported nothing. That is deployment relevant: abi_serializer accepts only a "sysio::abi/1." prefix. The full string is compared; the numeric parse still runs to reject an unsupported version. ABIMerger::merge_version returned the winning document's raw string, so merging an eosio::abi/1.10 descriptor emitted an ABI stamped the same way, which the runtime rejects outright. Ordering still ignores the prefix, so a foreign descriptor can be ingested, but the merged version is canonicalised through abi_version::version_string. abidiff_tests 46, abi_version_tests 57, staged_headers 13. ctest 31/31, abigen-pass 16/16, and every generated ABI still 1.2 (or 1.3 for protobuf). --- libraries/CMakeLists.txt | 9 +++-- libraries/native/CMakeLists.txt | 13 +++++-- tests/unit/abidiff_tests.sh | 45 +++++++++++++++------- tests/unit/staged_headers_tests.sh | 20 ++++++++-- tools/abidiff/cdt-abidiff.cpp.in | 61 ++++++++++++++++++++---------- tools/include/sysio/abimerge.hpp | 11 +++++- 6 files changed, 114 insertions(+), 45 deletions(-) diff --git a/libraries/CMakeLists.txt b/libraries/CMakeLists.txt index 0949ec546..6d19a8175 100644 --- a/libraries/CMakeLists.txt +++ b/libraries/CMakeLists.txt @@ -36,9 +36,12 @@ add_subdirectory(libc++) add_subdirectory(sysiolib) add_subdirectory(rt) -if (ENABLE_NATIVE_COMPILER) - add_subdirectory(native) -endif() +# Added unconditionally. The directory defines the WebAssembly softfloat archive `sf`, which +# cdt-ld links with -lsf for --use-rt and every --fquery mode, alongside the native-host +# targets -- and only the latter depend on ENABLE_NATIVE_COMPILER. Skipping the whole +# directory meant a clean OFF build produced no libsf.a at all, so an OFF package could not +# link those Wasm modes. The native-host targets are gated inside the file instead. +add_subdirectory(native) # Anything compiled against the staged tree must see the pruned copy, not a leftover. # diff --git a/libraries/native/CMakeLists.txt b/libraries/native/CMakeLists.txt index e9e135e15..e0bb9758a 100644 --- a/libraries/native/CMakeLists.txt +++ b/libraries/native/CMakeLists.txt @@ -355,12 +355,17 @@ list( APPEND native_softfloat_sources ${native_softfloat_headers} ) add_library ( sf STATIC ${softfloat_sources} ) target_include_directories( sf PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/softfloat/source/include" "${CMAKE_CURRENT_SOURCE_DIR}/${SOFTFLOAT_SPECIALIZE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/${SOFTFLOAT_PLATFORM_DIR}" ${CMAKE_SOURCE_DIR}) -add_native_library ( native STATIC ${native_softfloat_sources} intrinsics.cpp crt.cpp ${CRT_ASM} ) -target_include_directories( native PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/softfloat/source/include" "${CMAKE_CURRENT_SOURCE_DIR}/${NATIVE_SOFTFLOAT_SPECIALIZE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/${NATIVE_SOFTFLOAT_PLATFORM_DIR}" ${CMAKE_SOURCE_DIR} ${CMAKE_SOURCE_DIR}/sysiolib/capi ${CMAKE_SOURCE_DIR}/sysiolib/contracts ${CMAKE_SOURCE_DIR}/sysiolib/core) +# The native-HOST library. Everything above this point -- including the `sf` archive -- is +# WebAssembly and is built in every configuration, because cdt-ld links -lsf for --use-rt and +# the --fquery modes regardless of whether the native tester is enabled. +if (ENABLE_NATIVE_COMPILER) + add_native_library ( native STATIC ${native_softfloat_sources} intrinsics.cpp crt.cpp ${CRT_ASM} ) + target_include_directories( native PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/softfloat/source/include" "${CMAKE_CURRENT_SOURCE_DIR}/${NATIVE_SOFTFLOAT_SPECIALIZE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/${NATIVE_SOFTFLOAT_PLATFORM_DIR}" ${CMAKE_SOURCE_DIR} ${CMAKE_SOURCE_DIR}/sysiolib/capi ${CMAKE_SOURCE_DIR}/sysiolib/contracts ${CMAKE_SOURCE_DIR}/sysiolib/core) -add_dependencies(native native_sysio) + add_dependencies(native native_sysio) -add_custom_command( TARGET native POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy $ ${BASE_BINARY_DIR}/lib ) + add_custom_command( TARGET native POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy $ ${BASE_BINARY_DIR}/lib ) +endif() add_custom_command( TARGET sf POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy $ ${BASE_BINARY_DIR}/lib ) diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index a71578d8a..340c16635 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -462,31 +462,48 @@ mk_legacy "${WORK}/l_r2.abi" "sysio::abi/1.1" '"action_results": [ { "name": "ge expect_reports "a 1.1 action_result difference is reported despite the version" \ "${WORK}/l_r1.abi" "${WORK}/l_r2.abi" "action_result" -# --- truncated documents ----------------------------------------------------------------- +# --- omitted vs empty sections -------------------------------------------------------------- # -# Reading REQUIRED sections through the absent-is-empty fallback made a truncated ABI compare -# equal to a complete one whose section is empty. Those keys are validated instead. +# Every list member of abi_def is a vector<> that default-constructs empty, so the chain reads +# an omitted key and an explicit empty array identically. A diff tool that reported them as +# different would be describing a difference the runtime does not see. An earlier revision +# required five sections to be present, which rejected schema-valid minimal documents. python3 - "${WORK}/upstream.abi" "${WORK}/no_actions.abi" <<'PYEOF' import json, sys a = json.load(open(sys.argv[1])); a.pop("actions", None) json.dump(a, open(sys.argv[2], "w")) PYEOF -if out="$(run_abidiff "${WORK}/no_actions.abi" "${WORK}/upstream.abi" 2>&1)"; then - fail "an ABI missing a required section is refused" - sed 's/^/ /' <<< "$out" -elif grep -q "missing the required ABI section" <<< "$out"; then - pass "an ABI missing a required section is refused" -else - fail "an ABI missing a required section is refused" - sed 's/^/ /' <<< "$out" -fi +expect_quiet "an omitted section equals an explicit empty one" \ + "${WORK}/no_actions.abi" "${WORK}/upstream.abi" "action" + +cat > "${WORK}/minimal_ok.abi" <<'EOF' +{ "version": "sysio::abi/1.2", "structs": [], "actions": [] } +EOF +expect_quiet "a minimal schema-valid document diffs cleanly against itself" \ + "${WORK}/minimal_ok.abi" "${WORK}/minimal_ok.abi" "." + +# The namespace prefix is deployment-relevant: the runtime accepts only "sysio::abi/1.". +cat > "${WORK}/minimal_eos.abi" <<'EOF' +{ "version": "eosio::abi/1.2", "structs": [], "actions": [] } +EOF +expect_reports "a differing ABI namespace is reported" \ + "${WORK}/minimal_ok.abi" "${WORK}/minimal_eos.abi" "version" # --- remaining payload sections ------------------------------------------------------------ mk_legacy "${WORK}/em1.abi" "sysio::abi/1.2" '"error_messages": []' mk_legacy "${WORK}/em2.abi" "sysio::abi/1.2" '"error_messages": [ { "error_code": 1, "error_msg": "boom" } ]' -expect_reports "a changed error_messages is reported" "${WORK}/em1.abi" "${WORK}/em2.abi" "error_messages" +expect_reports "a changed error_messages is reported" "${WORK}/em1.abi" "${WORK}/em2.abi" "error_message" expect_quiet "an identical error_messages reports no difference" \ - "${WORK}/em1.abi" "${WORK}/em1.abi" "error_messages" + "${WORK}/em1.abi" "${WORK}/em1.abi" "error_message" + +# Consumed into a map keyed by error_code, so order is not significant and omission equals []. +mk_legacy "${WORK}/em_ab.abi" "sysio::abi/1.2" '"error_messages": [ { "error_code": 1, "error_msg": "a" }, { "error_code": 2, "error_msg": "b" } ]' +mk_legacy "${WORK}/em_ba.abi" "sysio::abi/1.2" '"error_messages": [ { "error_code": 2, "error_msg": "b" }, { "error_code": 1, "error_msg": "a" } ]' +expect_quiet "reordered error_messages report no difference" \ + "${WORK}/em_ab.abi" "${WORK}/em_ba.abi" "error_message" +mk_legacy "${WORK}/em_absent.abi" "sysio::abi/1.2" '"types": []' +expect_quiet "an omitted error_messages equals an empty one" \ + "${WORK}/em_absent.abi" "${WORK}/em1.abi" "error_message" mk_legacy "${WORK}/ax1.abi" "sysio::abi/1.2" '"abi_extensions": []' mk_legacy "${WORK}/ax2.abi" "sysio::abi/1.2" '"abi_extensions": [ [ 1, "00" ] ]' diff --git a/tests/unit/staged_headers_tests.sh b/tests/unit/staged_headers_tests.sh index 4ddd34568..36593b812 100755 --- a/tests/unit/staged_headers_tests.sh +++ b/tests/unit/staged_headers_tests.sh @@ -182,10 +182,14 @@ else for d in "${INCLUDE_DIR}/sysio/native" "${INCLUDE_DIR}/sysiolib/native"; do [ -d "$d" ] && leftovers+=("$d") done - # The native archives are copied into lib/ by POST_BUILD commands that only exist while - # native mode is on. They survive a reconfigure to OFF, and InstallCDT installs lib/ + # The native-HOST archives are copied into lib/ by POST_BUILD commands that only exist + # while native mode is on. They survive a reconfigure to OFF, and InstallCDT installs lib/ # wholesale, so a stale one gets packaged carrying the previous build's symbols. - for f in "${BUILD_DIR}"/lib/libnative* "${BUILD_DIR}/lib/libsf.a"; do + # + # libnative* only. libsf.a is WebAssembly and is built in every configuration, so it is + # required below rather than forbidden here -- listing it as a leftover contradicted the + # isolated probe, which requires the same file to survive. + for f in "${BUILD_DIR}"/lib/libnative*; do [ -e "$f" ] && leftovers+=("$f") done if [ "${#leftovers[@]}" -eq 0 ]; then @@ -194,6 +198,14 @@ else fail "native headers and archives are absent (native disabled)" for d in "${leftovers[@]}"; do echo " still staged: $d"; done fi + + # ...and the wasm softfloat archive must be PRESENT, in this mode as in any other. + if [ -e "${BUILD_DIR}/lib/libsf.a" ]; then + pass "libsf.a is present (native disabled)" + else + fail "libsf.a is present (native disabled)" + echo " cdt-ld links -lsf for --use-rt and the --fquery modes" + fi fi # --- ON -> OFF prune, in an isolated tree ------------------------------------------ @@ -218,6 +230,8 @@ else if cmake -DSTAGE_SOURCE_DIR="${SOURCE_DIR}/libraries" -DSTAGE_BINARY_DIR="${SCRATCH}" \ -DSTAGE_NATIVE=0 -P "${SOURCE_DIR}/cmake/stage_cdt_tree.cmake" \ > "${SCRATCH}/stage.log" 2>&1; then + # libnative* and the native header trees only. libsf.a is asserted separately, and + # positively: it is a WebAssembly archive built in every configuration. leftovers=() for f in "${SCRATCH}/lib/libnative.a" "${SCRATCH}/lib/libnative_sysio.a" \ "${SCRATCH}/include/sysio/native" "${SCRATCH}/include/sysiolib/native"; do diff --git a/tools/abidiff/cdt-abidiff.cpp.in b/tools/abidiff/cdt-abidiff.cpp.in index b1720f1dd..51fb4e998 100644 --- a/tools/abidiff/cdt-abidiff.cpp.in +++ b/tools/abidiff/cdt-abidiff.cpp.in @@ -76,8 +76,15 @@ class abidiff { return {major_v, minor_v}; } + // The FULL string, not just the parsed components. parse_version_string ignores + // everything through the last '/', which is right for deciding capability and wrong + // here: Wire's abi_serializer accepts only a "sysio::abi/1." prefix, so an otherwise + // identical eosio::abi/1.2 is a deployment-relevant difference. The numeric parse still + // runs, to reject an unsupported version. void diff_version() { - if (get_version(abi_1, "file1") != get_version(abi_2, "file2")) { + (void)get_version(abi_1, "file1"); + (void)get_version(abi_2, "file2"); + if (field_or_null(abi_1, "version") != field_or_null(abi_2, "version")) { std::cout << "< version\n\t"; std::cout << abi_1["version"] << "\n"; std::cout << "> version\n\t"; @@ -102,19 +109,11 @@ class abidiff { return o.count(key) ? o.at(key) : none; } - // The sections the ABI schema requires. Reading these through section_or_empty too - // would make a TRUNCATED document compare equal to a complete one whose section is - // empty -- so a missing `actions` key would diff clean against `"actions": []`. Master - // threw on the missing key; this reports it and exits non-zero instead. - static void require_sections(const ojson& abi, const char* which) { - for (const char* key : {"types", "structs", "actions", "tables", "ricardian_clauses"}) { - if (!abi.count(key)) { - std::cerr << "Error: " << which << " is missing the required ABI section '" - << key << "'\n"; - exit(1); - } - } - } + // No section is required. Every list member of abi_def is a vector<> that + // default-constructs empty, so the chain reads an absent key and an explicit empty array + // identically -- reporting them as different would describe a difference the runtime does + // not see. `version` is the exception, and get_version diagnoses a missing or unparsable + // one. static const ojson& field_or_null(const ojson& o, const char* key) { static const ojson absent = ojson::null(); @@ -404,18 +403,42 @@ class abidiff { // error_messages and abi_extensions are the remaining abi_def payload sections. CDT // does not emit either, but an ABI produced elsewhere can carry them, and a change // there is a real interface change. Compared whole, like protobuf_types. + // Read through section_or_empty, not field_or_null: an omitted optional section printed + // as `null` against an explicit `[]`, a difference the chain does not see. void diff_opaque_section(const char* key) { - const ojson& a = field_or_null(abi_1, key); - const ojson& b = field_or_null(abi_2, key); + const ojson& a = section_or_empty(abi_1, key); + const ojson& b = section_or_empty(abi_2, key); if (a != b) { std::cout << "< " << key << "\n" << pretty_print(a) << "\n"; std::cout << "> " << key << "\n" << pretty_print(b) << "\n"; } } + // error_messages is consumed into a map keyed by error_code, so the same codes listed in + // a different order are the same ABI. Matched by key, like every other named section. + void find_error_messages(const ojson& abi1, const ojson& abi2, char direction) { + const ojson& e1 = section_or_empty(abi1, "error_messages"); + const ojson& e2 = section_or_empty(abi2, "error_messages"); + for ( size_t i = 0; i < e1.size(); i++ ) { + bool found = false; + for ( size_t j = 0; j < e2.size(); j++ ) { + if (field_or_null(e1.at(i), "error_code") != field_or_null(e2.at(j), "error_code")) + continue; + found = field_or_null(e1.at(i), "error_msg") == field_or_null(e2.at(j), "error_msg"); + break; + } + if (!found) { + std::cout << direction << " error_message\n" << pretty_print(e1.at(i)) << "\n"; + } + } + } + + void diff_error_messages() { + find_error_messages(abi_1, abi_2, '<'); + find_error_messages(abi_2, abi_1, '>'); + } + void diff() { - require_sections(abi_1, "file1"); - require_sections(abi_2, "file2"); diff_version(); diff_structs(); diff_types(); @@ -433,7 +456,7 @@ class abidiff { // genuinely omits them compares as empty rather than throwing. diff_variants(); diff_action_results(); - diff_opaque_section("error_messages"); + diff_error_messages(); diff_opaque_section("abi_extensions"); } }; diff --git a/tools/include/sysio/abimerge.hpp b/tools/include/sysio/abimerge.hpp index 6b2a01619..c92b86262 100644 --- a/tools/include/sysio/abimerge.hpp +++ b/tools/include/sysio/abimerge.hpp @@ -132,9 +132,16 @@ class ABIMerger { return {major_v, minor_v}; } + /// The newer of the two versions, canonicalised to this toolchain's namespace. + /// + /// Returning the winning document's raw string emitted whatever prefix it carried: a + /// descriptor declaring "eosio::abi/1.10" produced a merged ABI stamped the same way, + /// which Wire's abi_serializer rejects outright -- it requires "sysio::abi/1.". Version + /// ORDERING ignores the prefix by design, so a foreign descriptor can still be ingested; + /// what it must not do is leave the output undeployable. std::string merge_version(ojson b) { - return version_of(abi) < version_of(b) ? b["version"].as() - : abi["version"].as(); + const auto winner = std::max(version_of(abi), version_of(b)); + return abi_version::version_string(winner.first, winner.second); } // Field order is significant: it is the serialization order, so {x,y} and {y,x} are From ec3b84d5160fdcdd7a3713b0fa1718bef30c5e14 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Wed, 2 Sep 2026 14:47:06 -0500 Subject: [PATCH 17/26] chore: normalise protobuf_types, and pin the fixes nothing was pinning One behaviour fix and four coverage gaps, each of which let a revert pass. protobuf_types is a may_not_exist holding a FileDescriptorSet as JSON, and the chain's custom from_variant accepts either spelling -- a JSON object, or a string containing that object -- while absent and an empty string are the same empty value. Comparing the raw node reported all three equivalent pairs as changed. Each side is reduced to one logical value first; an object and its string encoding, and absent and "", now compare equal, while present-vs-absent still differs. The four gaps, all confirmed by ablation: - Nothing exercised the native-disabled configuration. Both workflows leave ENABLE_NATIVE_COMPILER at its ON default, and the scratch prune probe seeds a fake libsf.a rather than building one -- so re-gating add_subdirectory (native) left every check green while a clean OFF package again shipped no softfloat archive. An always-run probe configures the libraries project with OFF and asserts the generated graph contains libsf.a and no libnative*. Configure-only: seconds, not a second full build. Re-gating now fails it. - No test reached ABIMerger with a foreign ABI prefix; the eosio:: fixture exercised only the differ. A merge case now proves an eosio::abi/1.10 descriptor yields sysio::abi/1.10, and reverting the canonicalisation fails two assertions. - abi_extensions had changed and identical cases but no omitted-versus-empty control, so reverting diff_opaque_section to field_or_null passed all 46. - The native-enabled branch checked include/sysio/native but not include/sysiolib/native, so the second tree could vanish silently. Both are asserted; removing either fails, naming the absent path. abidiff_tests 50, abi_version_tests 59, staged_headers 15. ctest 31/31, abigen-pass 16/16. --- tests/unit/abi_version_tests.sh | 23 +++++++++++ tests/unit/abidiff_tests.sh | 22 +++++++++++ tests/unit/staged_headers_tests.sh | 62 ++++++++++++++++++++++++++++-- tools/abidiff/cdt-abidiff.cpp.in | 25 +++++++++++- 4 files changed, 127 insertions(+), 5 deletions(-) diff --git a/tests/unit/abi_version_tests.sh b/tests/unit/abi_version_tests.sh index 1d6b5690c..ecb3a39d0 100755 --- a/tests/unit/abi_version_tests.sh +++ b/tests/unit/abi_version_tests.sh @@ -408,6 +408,29 @@ sed 's/25830/40000/' "${WORK}/t_rich.desc" > "${WORK}/t_otherid.desc" merge_refuses_both_orders "tables with different table_ids conflict" \ "${WORK}/t_rich.desc" "${WORK}/t_otherid.desc" +# A descriptor declaring a foreign ABI namespace must not produce one. Ordering ignores the +# prefix by design, so an eosio:: descriptor can be ingested -- but merge_version returned the +# winning document's RAW string, so the merged ABI was stamped eosio::abi/1.10, which the +# runtime's abi_serializer rejects outright. Nothing reached ABIMerger with a foreign prefix +# before this: the eosio:: fixture in abidiff_tests exercises only the differ. +printf '{"version":"sysio::abi/1.2",%s,"tables":[]}\n' "$TBL_COMMON" > "${WORK}/ns_local.desc" +printf '{"version":"eosio::abi/1.10",%s,"tables":[]}\n' "$TBL_COMMON" > "${WORK}/ns_foreign.desc" +ns_dir="${WORK}/ns"; mkdir -p "$ns_dir" +cp "${WORK}/ns_local.desc" "${ns_dir}/a_first.desc" +cp "${WORK}/ns_foreign.desc" "${ns_dir}/b_second.desc" +if "$CDT_CODEGEN" --finalize --contract ns --output-dir "$ns_dir" \ + --abi-output "${ns_dir}/ns.abi" \ + --desc-file "${ns_dir}/a_first.desc" --desc-file "${ns_dir}/b_second.desc" \ + > "${ns_dir}/ns.log" 2>&1; then + check "a foreign ABI namespace is canonicalised on merge" \ + "${ns_dir}/ns.abi" '"version": "sysio::abi/1.10"' + check_absent "the merged ABI carries no foreign namespace" \ + "${ns_dir}/ns.abi" 'eosio::abi' +else + fail "a descriptor with a foreign namespace merges" + sed 's/^/ /' "${ns_dir}/ns.log" +fi + mkdesc_variant "${WORK}/v_short.desc" '["uint64"]' mkdesc_variant "${WORK}/v_long.desc" '["uint64","string"]' merge_refuses_both_orders "variants of differing length conflict" \ diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index 340c16635..f7642f7e2 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -508,6 +508,28 @@ expect_quiet "an omitted error_messages equals an empty one" \ mk_legacy "${WORK}/ax1.abi" "sysio::abi/1.2" '"abi_extensions": []' mk_legacy "${WORK}/ax2.abi" "sysio::abi/1.2" '"abi_extensions": [ [ 1, "00" ] ]' expect_reports "a changed abi_extensions is reported" "${WORK}/ax1.abi" "${WORK}/ax2.abi" "abi_extensions" +# Omitted equals empty. Without this, reverting diff_opaque_section from section_or_empty back +# to field_or_null leaves every other case green while `null` is printed against `[]`. +mk_legacy "${WORK}/ax_absent.abi" "sysio::abi/1.2" '"types": []' +expect_quiet "an omitted abi_extensions equals an empty one" \ + "${WORK}/ax_absent.abi" "${WORK}/ax1.abi" "abi_extensions" + +# --- protobuf_types spellings --------------------------------------------------------------- +# +# may_not_exist holding a FileDescriptorSet as JSON. The chain's from_variant accepts +# either a JSON object or a string containing that object, and absent equals an empty string -- +# so all three pairs below are the same ABI and a raw node comparison reported each as changed. +PB_BASE='"version":"sysio::abi/1.3","structs":[],"actions":[]' +printf '{%s,"protobuf_types":{"file":[{"name":"a.proto","package":"t"}]}}\n' "$PB_BASE" > "${WORK}/pb_obj.abi" +printf '{%s,"protobuf_types":"{\\"file\\":[{\\"name\\":\\"a.proto\\",\\"package\\":\\"t\\"}]}"}\n' "$PB_BASE" > "${WORK}/pb_str.abi" +printf '{%s,"protobuf_types":""}\n' "$PB_BASE" > "${WORK}/pb_empty.abi" +printf '{%s}\n' "$PB_BASE" > "${WORK}/pb_absent.abi" +expect_quiet "an object and its JSON-string encoding are the same protobuf_types" \ + "${WORK}/pb_obj.abi" "${WORK}/pb_str.abi" "protobuf_types" +expect_quiet "an omitted protobuf_types equals an empty string" \ + "${WORK}/pb_absent.abi" "${WORK}/pb_empty.abi" "protobuf_types" +expect_reports "a present protobuf_types differs from an absent one" \ + "${WORK}/pb_obj.abi" "${WORK}/pb_absent.abi" "protobuf_types" echo "" echo "Results: ${PASS} passed, ${FAIL} failed" diff --git a/tests/unit/staged_headers_tests.sh b/tests/unit/staged_headers_tests.sh index 36593b812..8a46138a4 100755 --- a/tests/unit/staged_headers_tests.sh +++ b/tests/unit/staged_headers_tests.sh @@ -172,10 +172,17 @@ fi # an API it was configured not to build. The counterpart check above cannot catch that: # those files still have source counterparts, they simply should not be there. if [ "$NATIVE_ENABLED" = "1" ]; then - if [ -d "${INCLUDE_DIR}/sysio/native" ]; then - pass "native headers are staged (native enabled)" + # BOTH destinations: staging owns include/sysio/native and include/sysiolib/native, and + # checking only the first left the second free to disappear with the suite still green. + missing_native=() + for d in "${INCLUDE_DIR}/sysio/native" "${INCLUDE_DIR}/sysiolib/native"; do + [ -d "$d" ] || missing_native+=("$d") + done + if [ "${#missing_native[@]}" -eq 0 ]; then + pass "both native header trees are staged (native enabled)" else - fail "native headers are staged (native enabled)" + fail "both native header trees are staged (native enabled)" + for d in "${missing_native[@]}"; do echo " absent: $d"; done fi else leftovers=() @@ -208,6 +215,55 @@ else fi fi +# --- native-disabled configuration, always run ------------------------------------------ +# +# Both workflows leave ENABLE_NATIVE_COMPILER at its ON default, so every OFF assertion above +# is dead in CI -- and the scratch prune probe seeds a fake libsf.a rather than building one, +# so it proves only that pruning spares the file. Re-gating add_subdirectory(native), or the sf +# target inside it, would leave all of that green while a clean OFF package again shipped no +# softfloat archive. +# +# Configuring is enough to catch that and costs seconds: the generated build graph either +# contains the `sf` target or it does not. Building it is left to the OFF matrix leg. +echo "-- native-disabled configuration --" + +if ! command -v cmake > /dev/null 2>&1; then + echo " SKIP: cmake not on PATH" +elif [ ! -f "${BUILD_DIR}/lib/cmake/cdt/CDTWasmToolchain.cmake" ]; then + echo " SKIP: no staged CDT toolchain file to configure against" +else + OFFDIR="$(mktemp -d)" + if ! cmake -S "${SOURCE_DIR}/libraries" -B "${OFFDIR}" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE="${BUILD_DIR}/lib/cmake/cdt/CDTWasmToolchain.cmake" \ + -DCDT_BIN="${BUILD_DIR}/lib/cmake/cdt/" \ + -DBASE_BINARY_DIR="${OFFDIR}/out" \ + -D__APPLE=FALSE \ + -DENABLE_NATIVE_COMPILER=OFF > "${OFFDIR}/cfg.log" 2>&1; then + fail "the libraries project configures with ENABLE_NATIVE_COMPILER=OFF" + sed 's/^/ /' "${OFFDIR}/cfg.log" + else + pass "the libraries project configures with ENABLE_NATIVE_COMPILER=OFF" + targets="$(ninja -C "${OFFDIR}" -t targets all 2>/dev/null || true)" + # sf is WebAssembly -- cdt-ld links -lsf for --use-rt and the --fquery modes -- so it + # must be built in every configuration, not only when the native tester is enabled. + if grep -q "libsf\.a" <<< "$targets"; then + pass "an OFF configuration still builds libsf.a" + else + fail "an OFF configuration still builds libsf.a" + echo " no libsf.a target in the generated graph" + fi + # ...while the native-host archives are correctly absent. + if grep -qE "libnative[a-z_]*\.a" <<< "$targets"; then + fail "an OFF configuration builds no libnative* archive" + grep -oE "libnative[a-z_]*\.a" <<< "$targets" | sort -u | sed 's/^/ still built: /' + else + pass "an OFF configuration builds no libnative* archive" + fi + fi + rm -rf "${OFFDIR}" +fi + # --- ON -> OFF prune, in an isolated tree ------------------------------------------ # # The checks above only describe the mode this build was configured in, and diff --git a/tools/abidiff/cdt-abidiff.cpp.in b/tools/abidiff/cdt-abidiff.cpp.in index 51fb4e998..ae560c78b 100644 --- a/tools/abidiff/cdt-abidiff.cpp.in +++ b/tools/abidiff/cdt-abidiff.cpp.in @@ -391,9 +391,30 @@ class abidiff { // protobuf_types is a whole serialized FileDescriptorSet, not a list keyed by name, so // it is compared as one value rather than element-wise. A difference anywhere in it // changes the wire encoding of every protobuf action. + // protobuf_types is a may_not_exist holding a FileDescriptorSet as JSON, and the + // chain's custom from_variant accepts either spelling: a JSON object, or a string + // containing that object's JSON. Absent and an empty string are likewise the same empty + // value. Comparing the raw node reported all three pairs as differences the runtime does + // not see, so each side is reduced to one logical value first. + static ojson canonical_protobuf(const ojson& o) { + if (!o.count("protobuf_types")) + return ojson::null(); + const ojson& v = o.at("protobuf_types"); + if (!v.is_string()) + return v; // already an object + const std::string text = v.as(); + if (text.empty()) + return ojson::null(); // empty string == absent + try { + return ojson::parse(text); // string encoding of the same object + } catch (const std::exception&) { + return v; // not valid JSON: compare as the string it is + } + } + void diff_protobuf_types() { - const ojson& p1 = field_or_null(abi_1, "protobuf_types"); - const ojson& p2 = field_or_null(abi_2, "protobuf_types"); + const ojson p1 = canonical_protobuf(abi_1); + const ojson p2 = canonical_protobuf(abi_2); if (p1 != p2) { std::cout << "< protobuf_types\n" << pretty_print(p1) << "\n"; std::cout << "> protobuf_types\n" << pretty_print(p2) << "\n"; From c3be9d7a10eb28532e7e5997c76dfe95b41c805c Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Wed, 2 Sep 2026 16:33:36 -0500 Subject: [PATCH 18/26] chore: build the OFF configuration in the probe, and restrict protobuf roots Four findings. The native-disabled probe listed targets instead of building. `ninja -t targets` shows a declared target even when it is EXCLUDE_FROM_ALL, and listing never runs the POST_BUILD copy -- so either change left the probe green while a default OFF package still shipped no softfloat archive. It builds the default graph now and asserts out/lib/libsf.a exists and holds WebAssembly objects. Costs about five seconds: these objects are already in the compiler cache from the main build. Re-gating add_subdirectory(native) fails it. The no-native-archive pattern was `libnative[a-z_]*\.a`, which excludes plus signs and so never matched the real libnative_c++.a -- ungating only the native libc++ would have passed. Matches the full archive surface now. The both-trees check tested directory existence, so a staging-pattern regression that created the destinations and copied nothing would ship a package with no native API while the assertion passed. Requires a nonzero file count in each, as the other trees already did. protobuf_types canonicalisation adopted any parseable string. The chain's JsonStringToMessage requires a message root, so a string holding "null" or an array is content it REJECTS -- decoding it equated `"null"` with absence, and a string `"[1,2]"` with a raw array the chain reads differently. Only an object root is adopted; anything else stays the string it is. Controls for both, and reverting the restriction fails them. Stated rather than implied: this is JSON-level canonicalisation. Two spellings that decode to the same FileDescriptorSet but differ as JSON are still reported as different, since collapsing them means parsing through FileDescriptorSet with the chain's options and this tool does not link protobuf. It errs toward reporting a difference that is not one, never toward hiding one. Also fixed in the probe: `find | head -1` took SIGPIPE, which under `set -o pipefail` exited the suite mid-run with 141 rather than failing a case. -print -quit instead. abidiff_tests 52, staged_headers 17. ctest 31/31, abigen-pass 16/16. --- tests/unit/abidiff_tests.sh | 12 +++++ tests/unit/staged_headers_tests.sh | 76 +++++++++++++++++++++++------- tools/abidiff/cdt-abidiff.cpp.in | 25 ++++++++-- 3 files changed, 90 insertions(+), 23 deletions(-) diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index f7642f7e2..159621256 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -531,6 +531,18 @@ expect_quiet "an omitted protobuf_types equals an empty string" \ expect_reports "a present protobuf_types differs from an absent one" \ "${WORK}/pb_obj.abi" "${WORK}/pb_absent.abi" "protobuf_types" +# Invalid roots. JsonStringToMessage requires a message, so a string holding "null" or an +# array is content the chain REJECTS -- decoding it and comparing the result would equate it +# with absence, or with a raw array the chain reads quite differently. Only an object root is +# adopted; everything else stays the string it is. +printf '{%s,"protobuf_types":"null"}\n' "$PB_BASE" > "${WORK}/pb_strnull.abi" +printf '{%s,"protobuf_types":[1,2]}\n' "$PB_BASE" > "${WORK}/pb_rawarr.abi" +printf '{%s,"protobuf_types":"[1,2]"}\n' "$PB_BASE" > "${WORK}/pb_strarr.abi" +expect_reports "a protobuf_types string of \"null\" differs from an absent one" \ + "${WORK}/pb_absent.abi" "${WORK}/pb_strnull.abi" "protobuf_types" +expect_reports "a raw array differs from a string containing that array" \ + "${WORK}/pb_rawarr.abi" "${WORK}/pb_strarr.abi" "protobuf_types" + echo "" echo "Results: ${PASS} passed, ${FAIL} failed" [ "$FAIL" -eq 0 ] diff --git a/tests/unit/staged_headers_tests.sh b/tests/unit/staged_headers_tests.sh index 8a46138a4..96b89ae90 100755 --- a/tests/unit/staged_headers_tests.sh +++ b/tests/unit/staged_headers_tests.sh @@ -174,15 +174,19 @@ fi if [ "$NATIVE_ENABLED" = "1" ]; then # BOTH destinations: staging owns include/sysio/native and include/sysiolib/native, and # checking only the first left the second free to disappear with the suite still green. + # A nonzero file count, not merely the directory. A staging-pattern regression that + # created the destinations and copied nothing would ship a package with no native API + # while a directory-existence check stayed green. missing_native=() for d in "${INCLUDE_DIR}/sysio/native" "${INCLUDE_DIR}/sysiolib/native"; do - [ -d "$d" ] || missing_native+=("$d") + n="$(find "$d" -type f 2>/dev/null | wc -l)" + [ "$n" -gt 0 ] || missing_native+=("$d ($n files)") done if [ "${#missing_native[@]}" -eq 0 ]; then - pass "both native header trees are staged (native enabled)" + pass "both native header trees are staged and non-empty (native enabled)" else - fail "both native header trees are staged (native enabled)" - for d in "${missing_native[@]}"; do echo " absent: $d"; done + fail "both native header trees are staged and non-empty (native enabled)" + for d in "${missing_native[@]}"; do echo " empty or absent: $d"; done fi else leftovers=() @@ -233,6 +237,10 @@ elif [ ! -f "${BUILD_DIR}/lib/cmake/cdt/CDTWasmToolchain.cmake" ]; then echo " SKIP: no staged CDT toolchain file to configure against" else OFFDIR="$(mktemp -d)" + # out/lib must exist before the build: the archives are staged by POST_BUILD + # `cmake -E copy /lib`, which writes a FILE named lib when the + # directory is absent. The real build tree always has it; an isolated probe must make it. + mkdir -p "${OFFDIR}/out/lib" if ! cmake -S "${SOURCE_DIR}/libraries" -B "${OFFDIR}" -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_TOOLCHAIN_FILE="${BUILD_DIR}/lib/cmake/cdt/CDTWasmToolchain.cmake" \ @@ -244,21 +252,53 @@ else sed 's/^/ /' "${OFFDIR}/cfg.log" else pass "the libraries project configures with ENABLE_NATIVE_COMPILER=OFF" - targets="$(ninja -C "${OFFDIR}" -t targets all 2>/dev/null || true)" - # sf is WebAssembly -- cdt-ld links -lsf for --use-rt and the --fquery modes -- so it - # must be built in every configuration, not only when the native tester is enabled. - if grep -q "libsf\.a" <<< "$targets"; then - pass "an OFF configuration still builds libsf.a" - else - fail "an OFF configuration still builds libsf.a" - echo " no libsf.a target in the generated graph" - fi - # ...while the native-host archives are correctly absent. - if grep -qE "libnative[a-z_]*\.a" <<< "$targets"; then - fail "an OFF configuration builds no libnative* archive" - grep -oE "libnative[a-z_]*\.a" <<< "$targets" | sort -u | sed 's/^/ still built: /' + + # BUILD it, do not merely list the targets. `ninja -t targets` shows a declared target + # even when it is EXCLUDE_FROM_ALL, and listing never runs the POST_BUILD copy into + # lib/ -- so either change would leave a listing check green while a default OFF + # package still shipped no softfloat archive. Building the default graph proves the + # target is reachable from `all` AND that the archive is staged. It costs a few + # seconds: these objects are already in the compiler cache from the main build. + if ! ninja -C "${OFFDIR}" > "${OFFDIR}/build.log" 2>&1; then + fail "the libraries project builds with ENABLE_NATIVE_COMPILER=OFF" + tail -20 "${OFFDIR}/build.log" | sed 's/^/ /' else - pass "an OFF configuration builds no libnative* archive" + pass "the libraries project builds with ENABLE_NATIVE_COMPILER=OFF" + + if [ -f "${OFFDIR}/out/lib/libsf.a" ]; then + pass "an OFF build stages libsf.a" + # ...and it is WebAssembly, not a host archive. cdt-ld links it with -lsf for + # --use-rt and the --fquery modes, so a host-built one would be useless. + probe_dir="${OFFDIR}/probe"; mkdir -p "$probe_dir" + ( cd "$probe_dir" && "${BUILD_DIR}/bin/llvm-ar" x "${OFFDIR}/out/lib/libsf.a" ) \ + > /dev/null 2>&1 || true + # -print -quit, not `| head -1`: head closes the pipe after one line, find + # takes SIGPIPE, and under `set -o pipefail` the assignment fails with 141 -- + # which `set -e` turns into a silent early exit mid-suite. Also parenthesised, + # so the -o binds to the two -name tests rather than to -print. + first_obj="$(find "$probe_dir" \( -name '*.obj' -o -name '*.o' \) -print -quit 2>/dev/null)" + if [ -n "$first_obj" ] && file -b "$first_obj" | grep -qi "webassembly"; then + pass "the staged libsf.a contains WebAssembly objects" + else + fail "the staged libsf.a contains WebAssembly objects" + echo " got: $(file -b "${first_obj:-}" 2>/dev/null)" + fi + else + fail "an OFF build stages libsf.a" + echo " cdt-ld links -lsf for --use-rt and the --fquery modes" + ls "${OFFDIR}/out/lib" 2>/dev/null | sed 's/^/ staged: /' + fi + + # ...while no native-HOST archive is produced. [^[:space:]]* rather than [a-z_]*: + # the real targets include libnative_c++.a, whose plus signs a + # letters-and-underscores class silently excludes. + stray_native="$(ls "${OFFDIR}/out/lib" 2>/dev/null | grep -E "^libnative[^[:space:]]*\.a$" || true)" + if [ -z "$stray_native" ]; then + pass "an OFF build stages no libnative* archive" + else + fail "an OFF build stages no libnative* archive" + sed 's/^/ staged: /' <<< "$stray_native" + fi fi fi rm -rf "${OFFDIR}" diff --git a/tools/abidiff/cdt-abidiff.cpp.in b/tools/abidiff/cdt-abidiff.cpp.in index ae560c78b..7135f6c01 100644 --- a/tools/abidiff/cdt-abidiff.cpp.in +++ b/tools/abidiff/cdt-abidiff.cpp.in @@ -394,22 +394,37 @@ class abidiff { // protobuf_types is a may_not_exist holding a FileDescriptorSet as JSON, and the // chain's custom from_variant accepts either spelling: a JSON object, or a string // containing that object's JSON. Absent and an empty string are likewise the same empty - // value. Comparing the raw node reported all three pairs as differences the runtime does + // value. Comparing the raw node reported all of those as differences the runtime does // not see, so each side is reduced to one logical value first. + // + // This is JSON-level canonicalisation, not protobuf-level: two spellings that decode to + // the same FileDescriptorSet but differ as JSON -- an enum written numerically in one + // and symbolically in the other, say -- are still reported as different. Collapsing + // those would mean parsing both through FileDescriptorSet with the chain's options, + // which this tool does not link protobuf to do. It errs toward reporting a difference + // that is not one, never toward hiding one. static ojson canonical_protobuf(const ojson& o) { if (!o.count("protobuf_types")) return ojson::null(); const ojson& v = o.at("protobuf_types"); if (!v.is_string()) - return v; // already an object + return v; // already an object (or something else; as-is) const std::string text = v.as(); if (text.empty()) - return ojson::null(); // empty string == absent + return ojson::null(); // empty string == absent, per to_variant try { - return ojson::parse(text); // string encoding of the same object + ojson parsed = ojson::parse(text); + // ONLY an object root. JsonStringToMessage requires a message, so a string + // holding "null" or "[1,2]" is content the chain rejects -- treating it as the + // value it decodes to would have equated it with absence, or with a raw array + // that the chain reads quite differently. Anything else stays the string it is, + // and therefore differs from both. + if (parsed.is_object()) + return parsed; } catch (const std::exception&) { - return v; // not valid JSON: compare as the string it is + // not valid JSON at all } + return v; } void diff_protobuf_types() { From fc675edea0270990dd2871345ee05b383cbc3715 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Wed, 2 Sep 2026 21:02:00 -0500 Subject: [PATCH 19/26] chore: refuse ABIs whose duplicate members would be silently dropped jsoncons keeps only the last of a repeated object member, so a duplicate is gone before any comparison sees it -- for every section, not only protobuf_types. The runtime disagrees: fc preserves both members and protobuf merges duplicate repeated fields, so a descriptor set written with two `file` members is [a,b] on chain and [b] here, and cdt-abidiff reported no difference against a document carrying only [b]. That also falsified this PR's own claim that the protobuf normalisation can only over-report. The vendored jsoncons has no option to reject duplicates at parse time, and linking protobuf to canonicalise through FileDescriptorSet is a dependency this tool does not carry -- so such a document is refused at load instead, via a SAX pass that tracks the keys of each open object. Refusing beats answering about a value the runtime never sees. The comment now states that exception rather than claiming a guarantee the tool cannot make. Verified: the reported pair exits 1 with a diagnostic naming the key, a clean pair still exits 0, and all 44 real ABIs in the tree are accepted. The detector immediately found two malformed fixtures of my own -- mk_legacy already emits "types", so passing '"types": []' duplicated it -- which is the kind of thing it exists to catch. Also on the staging probe: - it checked only out/lib, so forcing STAGE_NATIVE=1 left native headers in a clean OFF tree with every assertion green. Since InstallCDT installs the whole include tree, those would have shipped. Both native include destinations are asserted absent; forcing the flag now fails. - `find` on a missing directory exits non-zero, and under `set -euo pipefail` that aborted the suite before the named failure or the Results line appeared -- so the one case the check exists to report was the one it could not print. A count_files helper returns 0 for an absent path; removing a native tree now reports it and the suite completes. abidiff_tests 53, staged_headers 18. ctest 31/31, abigen-pass 16/16. --- tests/unit/abidiff_tests.sh | 21 ++++++- tests/unit/staged_headers_tests.sh | 25 ++++++++- tools/abidiff/cdt-abidiff.cpp.in | 90 ++++++++++++++++++++++++++++-- 3 files changed, 126 insertions(+), 10 deletions(-) diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index 159621256..d8219a435 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -501,7 +501,7 @@ mk_legacy "${WORK}/em_ab.abi" "sysio::abi/1.2" '"error_messages": [ { "error_cod mk_legacy "${WORK}/em_ba.abi" "sysio::abi/1.2" '"error_messages": [ { "error_code": 2, "error_msg": "b" }, { "error_code": 1, "error_msg": "a" } ]' expect_quiet "reordered error_messages report no difference" \ "${WORK}/em_ab.abi" "${WORK}/em_ba.abi" "error_message" -mk_legacy "${WORK}/em_absent.abi" "sysio::abi/1.2" '"types": []' +mk_legacy "${WORK}/em_absent.abi" "sysio::abi/1.2" '"variants": []' expect_quiet "an omitted error_messages equals an empty one" \ "${WORK}/em_absent.abi" "${WORK}/em1.abi" "error_message" @@ -510,7 +510,7 @@ mk_legacy "${WORK}/ax2.abi" "sysio::abi/1.2" '"abi_extensions": [ [ 1, "00" ] ]' expect_reports "a changed abi_extensions is reported" "${WORK}/ax1.abi" "${WORK}/ax2.abi" "abi_extensions" # Omitted equals empty. Without this, reverting diff_opaque_section from section_or_empty back # to field_or_null leaves every other case green while `null` is printed against `[]`. -mk_legacy "${WORK}/ax_absent.abi" "sysio::abi/1.2" '"types": []' +mk_legacy "${WORK}/ax_absent.abi" "sysio::abi/1.2" '"variants": []' expect_quiet "an omitted abi_extensions equals an empty one" \ "${WORK}/ax_absent.abi" "${WORK}/ax1.abi" "abi_extensions" @@ -543,6 +543,23 @@ expect_reports "a protobuf_types string of \"null\" differs from an absent one" expect_reports "a raw array differs from a string containing that array" \ "${WORK}/pb_rawarr.abi" "${WORK}/pb_strarr.abi" "protobuf_types" +# Duplicate object members. jsoncons keeps only the last, so this document would compare equal +# to one carrying just the second -- while fc preserves both and protobuf merges duplicate +# repeated fields, making it [a,b] on chain and [b] here. Refused rather than mis-compared. +printf '{%s,"protobuf_types":{"file":[{"name":"a.proto"}],"file":[{"name":"b.proto"}]}}\n' \ + "$PB_BASE" > "${WORK}/pb_dup.abi" +printf '{%s,"protobuf_types":"{\\"file\\":[{\\"name\\":\\"b.proto\\"}]}"}\n' \ + "$PB_BASE" > "${WORK}/pb_onlyb.abi" +if out="$(run_abidiff "${WORK}/pb_dup.abi" "${WORK}/pb_onlyb.abi" 2>&1)"; then + fail "a document with duplicate object members is refused" + sed 's/^/ /' <<< "$out" +elif grep -q "duplicate object member" <<< "$out"; then + pass "a document with duplicate object members is refused" +else + fail "a document with duplicate object members is refused" + sed 's/^/ /' <<< "$out" +fi + echo "" echo "Results: ${PASS} passed, ${FAIL} failed" [ "$FAIL" -eq 0 ] diff --git a/tests/unit/staged_headers_tests.sh b/tests/unit/staged_headers_tests.sh index 96b89ae90..9b912dc2e 100755 --- a/tests/unit/staged_headers_tests.sh +++ b/tests/unit/staged_headers_tests.sh @@ -101,8 +101,13 @@ fi # Per destination, not just in aggregate. A single total cannot show that every tree was # staged: dropping the bluegrass copy from stage_cdt_tree.cmake leaves the count nonzero on # the strength of the other five, and the test stays green. +# count_files: 0 for a missing directory rather than a non-zero find. Under `set -euo +# pipefail` an unguarded `find` on an absent path aborts the suite mid-run -- the failure this +# check exists to report is exactly when the path is absent, so it would never be printed. +count_files() { [ -d "$1" ] || { echo 0; return 0; }; find "$1" -type f 2>/dev/null | wc -l; } + for dest in sysiolib libc libcxx boost/preprocessor bluegrass; do - n="$(find "${INCLUDE_DIR}/${dest}" -type f 2>/dev/null | wc -l)" + n="$(count_files "${INCLUDE_DIR}/${dest}")" if [ "$n" -gt 0 ]; then pass "${dest} is staged (${n} files)" else @@ -179,7 +184,7 @@ if [ "$NATIVE_ENABLED" = "1" ]; then # while a directory-existence check stayed green. missing_native=() for d in "${INCLUDE_DIR}/sysio/native" "${INCLUDE_DIR}/sysiolib/native"; do - n="$(find "$d" -type f 2>/dev/null | wc -l)" + n="$(count_files "$d")" [ "$n" -gt 0 ] || missing_native+=("$d ($n files)") done if [ "${#missing_native[@]}" -eq 0 ]; then @@ -289,6 +294,22 @@ else ls "${OFFDIR}/out/lib" 2>/dev/null | sed 's/^/ staged: /' fi + # ...and no native HEADER tree is staged. Checking lib/ alone does not pin the + # CMake-to-staging wiring: forcing STAGE_NATIVE=1 in libraries/CMakeLists.txt + # leaves these populated in an OFF build, and InstallCDT installs the whole + # include tree, so they would ship. + stray_hdrs=() + for d in "${OFFDIR}/out/include/sysio/native" "${OFFDIR}/out/include/sysiolib/native"; do + n="$(count_files "$d")" + [ "$n" -eq 0 ] || stray_hdrs+=("$d ($n files)") + done + if [ "${#stray_hdrs[@]}" -eq 0 ]; then + pass "an OFF build stages no native header tree" + else + fail "an OFF build stages no native header tree" + for d in "${stray_hdrs[@]}"; do echo " staged: $d"; done + fi + # ...while no native-HOST archive is produced. [^[:space:]]* rather than [a-z_]*: # the real targets include libnative_c++.a, whose plus signs a # letters-and-underscores class silently excludes. diff --git a/tools/abidiff/cdt-abidiff.cpp.in b/tools/abidiff/cdt-abidiff.cpp.in index 7135f6c01..48a43da4c 100644 --- a/tools/abidiff/cdt-abidiff.cpp.in +++ b/tools/abidiff/cdt-abidiff.cpp.in @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -22,26 +23,99 @@ struct abidiff_exception : public std::exception { } abidiff_ex; +// Rejects a document containing duplicate object members. +// +// jsoncons keeps only the last of a repeated key, so a duplicate is gone before any comparison +// sees it -- and it is gone for EVERY section, not only protobuf_types. The runtime does not +// agree: fc preserves both members and protobuf merges duplicate repeated fields, so a +// descriptor set written with two `file` members is [a,b] on chain and [b] here. Comparing such +// a document would silently answer about a value the chain never sees, so it is refused +// instead. The vendored jsoncons has no option to reject duplicates at parse time; this walks +// the same text through its SAX reader and tracks the keys of each open object. +class duplicate_member_detector final : public jsoncons::json_content_handler +{ +public: + bool found() const { return found_; } + const std::string& duplicate() const { return dup_; } + +private: + std::vector> stack_; + std::string dup_; + bool found_ = false; + + void do_begin_document() override {} + void do_end_document() override {} + void do_begin_object(const jsoncons::serializing_context&) override { stack_.emplace_back(); } + void do_end_object(const jsoncons::serializing_context&) override { if (!stack_.empty()) stack_.pop_back(); } + void do_begin_array(const jsoncons::serializing_context&) override {} + void do_end_array(const jsoncons::serializing_context&) override {} + void do_name(const string_view_type& name, const jsoncons::serializing_context&) override { + if (stack_.empty()) + return; + const std::string key(name.data(), name.size()); + if (!stack_.back().insert(key).second && !found_) { + found_ = true; + dup_ = key; + } + } + void do_null_value(const jsoncons::serializing_context&) override {} + void do_string_value(const string_view_type&, const jsoncons::serializing_context&) override {} + void do_byte_string_value(const uint8_t*, size_t, const jsoncons::serializing_context&) override {} + void do_bignum_value(int, const uint8_t*, size_t, const jsoncons::serializing_context&) override {} + void do_double_value(double, const jsoncons::floating_point_options&, const jsoncons::serializing_context&) override {} + void do_integer_value(int64_t, const jsoncons::serializing_context&) override {} + void do_uinteger_value(uint64_t, const jsoncons::serializing_context&) override {} + void do_bool_value(bool, const jsoncons::serializing_context&) override {} +}; + +/// @return the duplicated key, or empty when the text has none. +inline std::string first_duplicate_member(const std::string& text) { + duplicate_member_detector det; + try { + std::istringstream is(text); + jsoncons::json_reader reader(is, det); + reader.read(); + } catch (const std::exception&) { + return {}; // malformed input is diagnosed by the ordinary parse + } + return det.found() ? det.duplicate() : std::string{}; +} + class abidiff { private: ojson abi_1, abi_2; std::string fn_1, fn_2; + + // Read a document, refusing one whose comparison would be unfaithful. + static ojson load_checked(const std::string& path, const char* which) { + std::ifstream in(path); + std::stringstream buf; + buf << in.rdbuf(); + const std::string text = buf.str(); + const std::string dup = first_duplicate_member(text); + if (!dup.empty()) { + std::cerr << "Error: " << which << " has a duplicate object member '" << dup + << "'. jsoncons keeps only the last, while the chain preserves both and\n" + "protobuf merges duplicate repeated fields -- so any comparison here\n" + "would describe a value the runtime never sees.\n"; + exit(1); + } + return ojson::parse(text); + } public: abidiff( const std::string& fn1, const std::string& fn2) { llvm::SmallString<128> _fn1, _fn2; if (!llvm::sys::fs::real_path(fn1, _fn1, true)) { - std::ifstream in(_fn1.str().str()); fn_1 = _fn1.str().str(); - abi_1 = ojson::parse(in); + abi_1 = load_checked(fn_1, "file1"); } else { std::cerr << "Error, invalid filepath { " << _fn1.str().str() << " }\n"; throw abidiff_ex; } if (!llvm::sys::fs::real_path(fn2, _fn2, true)) { - std::ifstream in(_fn2.str().str()); fn_2 = _fn2.str().str(); - abi_2 = ojson::parse(in); + abi_2 = load_checked(fn_2, "file2"); } else { std::cerr << "Error, invalid filepath { " << _fn2.str().str() << " }\n"; throw abidiff_ex; @@ -401,8 +475,12 @@ class abidiff { // the same FileDescriptorSet but differ as JSON -- an enum written numerically in one // and symbolically in the other, say -- are still reported as different. Collapsing // those would mean parsing both through FileDescriptorSet with the chain's options, - // which this tool does not link protobuf to do. It errs toward reporting a difference - // that is not one, never toward hiding one. + // which this tool does not link protobuf to do. + // + // So it over-reports rather than under-reports, with ONE exception it cannot resolve + // here: jsoncons keeps only the last of a duplicate object member, which would hide a + // real difference. Such a document is refused at load (see load_checked) rather than + // compared. static ojson canonical_protobuf(const ojson& o) { if (!o.count("protobuf_types")) return ojson::null(); From 5578e17a5b30fd63859234677344cb7fc7627a73 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Thu, 3 Sep 2026 14:34:30 -0500 Subject: [PATCH 20/26] chore: check duplicates inside the string spelling, and assert what OFF must stage Three findings from review, each verified by mutation. The duplicate-member detector walked only the outer ABI document, so duplicates inside a string-valued protobuf_types were invisible to it: that spelling is one opaque string value to the SAX reader. canonical_protobuf then parsed the string with jsoncons, which keeps only the last member, and a descriptor set carrying `file: a.proto` and `file: b.proto` compared EQUAL to one carrying only b.proto -- no output, exit 0 -- while JsonStringToMessage reads the original as both. The same runtime-visible false negative the detector exists to prevent, one level down. load_checked now runs the detector on the string's own text and refuses the document on the same terms as the object spelling; the message and the protobuf_types key move into file-local helpers so both paths share them. The isolated OFF build asserted only negatives -- no native headers, no libnative* archive -- and never that the required trees were staged. Gating the sysiolib, libc, libcxx, boost/preprocessor and bluegrass copies on STAGE_NATIVE left all 18 assertions green while an OFF package shipped no public headers at all; the live tree cannot catch that, being configured ON. The per-destination check is now a helper run against both roots. The ON -> OFF prune probe seeded two of the five native archives a real build stages. Narrowing the GLOB to exactly those two kept every assertion green while libnative_c.a, libnative_c++.a and libnative_rt.a survived a reconfigure and were packaged. Both the seeding and the assertion now walk one list of all five, libnative_c++.a included -- the name whose plus signs a hand-written character class drops. --- tests/unit/abidiff_tests.sh | 45 ++++++++++++++++----- tests/unit/staged_headers_tests.sh | 63 +++++++++++++++++++++++------- tools/abidiff/cdt-abidiff.cpp.in | 55 +++++++++++++++++++++----- 3 files changed, 129 insertions(+), 34 deletions(-) diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index d8219a435..9198d9e2a 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -215,6 +215,24 @@ expect_reports() { # $1=desc $2=a $3=b $4=needle elif grep -q "$4" <<< "$out"; then pass "$1" else fail "$1"; sed 's/^/ /' <<< "$out"; fi } +# A document whose comparison would be unfaithful must be REFUSED -- non-zero exit and a +# diagnostic naming the repeated member -- not compared. Shared, because both spellings of +# protobuf_types reach it and an inline copy per spelling is how one of them stops being +# checked at all. +expect_refused() { # $1=desc $2=a $3=b + local out + if out="$(run_abidiff "$2" "$3" 2>&1)"; then + fail "$1" + echo " compared cleanly instead of refusing" + sed 's/^/ /' <<< "$out" + elif grep -q "duplicate object member" <<< "$out"; then + pass "$1" + else + fail "$1" + sed 's/^/ /' <<< "$out" + fi +} + expect_quiet() { # $1=desc $2=a $3=b $4=needle if ! capture "$1" "$2" "$3"; then : elif grep -q "$4" <<< "$out"; then fail "$1"; sed 's/^/ /' <<< "$out" @@ -550,15 +568,24 @@ printf '{%s,"protobuf_types":{"file":[{"name":"a.proto"}],"file":[{"name":"b.pro "$PB_BASE" > "${WORK}/pb_dup.abi" printf '{%s,"protobuf_types":"{\\"file\\":[{\\"name\\":\\"b.proto\\"}]}"}\n' \ "$PB_BASE" > "${WORK}/pb_onlyb.abi" -if out="$(run_abidiff "${WORK}/pb_dup.abi" "${WORK}/pb_onlyb.abi" 2>&1)"; then - fail "a document with duplicate object members is refused" - sed 's/^/ /' <<< "$out" -elif grep -q "duplicate object member" <<< "$out"; then - pass "a document with duplicate object members is refused" -else - fail "a document with duplicate object members is refused" - sed 's/^/ /' <<< "$out" -fi +expect_refused "a document with duplicate object members is refused" \ + "${WORK}/pb_dup.abi" "${WORK}/pb_onlyb.abi" + +# The same duplicate one level down, inside the STRING spelling -- where the outer walk sees +# only an opaque string value. This passed the check, was then parsed by jsoncons (which keeps +# the last member), and compared EQUAL to a string carrying just b.proto: no output, exit 0. +# JsonStringToMessage reads the original as BOTH descriptors, so that is exactly the runtime- +# visible false negative the detector exists to prevent, and it survived until the check was +# run on the string's own text. +printf '{%s,"protobuf_types":"{\\"file\\":[{\\"name\\":\\"a.proto\\"}],\\"file\\":[{\\"name\\":\\"b.proto\\"}]}"}\n' \ + "$PB_BASE" > "${WORK}/pb_dup_str.abi" +expect_refused "a duplicate member inside a protobuf_types string is refused" \ + "${WORK}/pb_dup_str.abi" "${WORK}/pb_onlyb.abi" + +# ...and refusing the string form must not come from refusing every string: the well-formed +# string spelling above still compares, and equal to its object counterpart. +expect_quiet "a duplicate-free protobuf_types string still compares" \ + "${WORK}/pb_str.abi" "${WORK}/pb_obj.abi" "protobuf_types" echo "" echo "Results: ${PASS} passed, ${FAIL} failed" diff --git a/tests/unit/staged_headers_tests.sh b/tests/unit/staged_headers_tests.sh index 9b912dc2e..3faf91acd 100755 --- a/tests/unit/staged_headers_tests.sh +++ b/tests/unit/staged_headers_tests.sh @@ -106,15 +106,38 @@ fi # check exists to report is exactly when the path is absent, so it would never be printed. count_files() { [ -d "$1" ] || { echo 0; return 0; }; find "$1" -type f 2>/dev/null | wc -l; } -for dest in sysiolib libc libcxx boost/preprocessor bluegrass; do - n="$(count_files "${INCLUDE_DIR}/${dest}")" - if [ "$n" -gt 0 ]; then - pass "${dest} is staged (${n} files)" - else - fail "${dest} is staged" - echo " nothing under ${INCLUDE_DIR}/${dest}" - fi -done +# Every destination stage_cdt_tree.cmake populates in EVERY configuration. Named once because +# three checks walk it -- this build tree, a fresh scratch staging, and the isolated OFF build +# below -- and a tree added to the script but missed in one of them is a gap the others cannot +# report. +readonly NON_NATIVE_DESTS=(sysiolib libc libcxx boost/preprocessor bluegrass) + +# Every native-host archive a native-enabled build stages into lib/, from the POST_BUILD copies +# in libraries/{native,sysiolib,libc,libc++,rt}/CMakeLists.txt. The ON -> OFF prune must remove +# ALL of them: seeding only a couple left `file(GLOB ... libnative*)` free to narrow to those +# names while the rest survived a reconfigure to OFF and were packaged, still carrying the +# previous build's symbols. libnative_c++.a is the one that matters most -- its plus signs are +# what a hand-written character class drops. +readonly NATIVE_ARCHIVES=(libnative.a libnative_sysio.a libnative_c.a libnative_c++.a libnative_rt.a) + +# Require each of those to be present AND non-empty under the include root $1, suffixing each +# result with $2. The file count, not the directory: a staging regression that created the +# destinations and copied nothing would ship a package with no public headers at all while a +# directory-existence check stayed green. +require_non_native_dests() { # $1=include root $2=label suffix + local root="$1" label="$2" dest n + for dest in "${NON_NATIVE_DESTS[@]}"; do + n="$(count_files "${root}/${dest}")" + if [ "$n" -gt 0 ]; then + pass "${dest} is staged${label} (${n} files)" + else + fail "${dest} is staged${label}" + echo " nothing under ${root}/${dest}" + fi + done +} + +require_non_native_dests "$INCLUDE_DIR" "" if [ "${#stale[@]}" -eq 0 ]; then pass "every staged header has a source counterpart" @@ -142,15 +165,15 @@ else sed 's/^/ /' "${PRUNE_SCRATCH}/stage.log" else planted=0 - for dest in sysiolib libc libcxx boost/preprocessor bluegrass; do + for dest in "${NON_NATIVE_DESTS[@]}"; do if [ -d "${PRUNE_SCRATCH}/include/${dest}" ]; then : > "${PRUNE_SCRATCH}/include/${dest}/zz_stale_probe.hpp" && planted=$((planted + 1)) else fail "fresh staging created ${dest}" fi done - if [ "$planted" -ne 5 ]; then - fail "planted a stale sentinel in each staged tree (planted ${planted}, expected 5)" + if [ "$planted" -ne "${#NON_NATIVE_DESTS[@]}" ]; then + fail "planted a stale sentinel in each staged tree (planted ${planted}, expected ${#NON_NATIVE_DESTS[@]})" elif ! cmake -DSTAGE_SOURCE_DIR="${SOURCE_DIR}/libraries" \ -DSTAGE_BINARY_DIR="${PRUNE_SCRATCH}" \ -DSTAGE_NATIVE="${NATIVE_ENABLED}" \ @@ -294,6 +317,14 @@ else ls "${OFFDIR}/out/lib" 2>/dev/null | sed 's/^/ staged: /' fi + # The required header trees ARE staged. Everything else this probe asserts is + # negative -- what an OFF build must not produce -- so removing the OFF sysiolib, + # libc, libcxx, boost/preprocessor and bluegrass outputs altogether left every + # assertion green while the package shipped no public headers. The live tree above + # cannot cover this: it is configured ON, so a regression gated on the OFF branch + # stages them there and is invisible. + require_non_native_dests "${OFFDIR}/out/include" " (native disabled)" + # ...and no native HEADER tree is staged. Checking lib/ alone does not pin the # CMake-to-staging wiring: forcing STAGE_NATIVE=1 in libraries/CMakeLists.txt # leaves these populated in an OFF build, and InstallCDT installs the whole @@ -340,7 +371,7 @@ else SCRATCH="$(mktemp -d)" trap 'rm -rf "$SCRATCH"' EXIT mkdir -p "${SCRATCH}/lib" "${SCRATCH}/include/sysio/native" "${SCRATCH}/include/sysiolib/native" - for f in libnative.a libnative_sysio.a libsf.a libc.a; do echo stale > "${SCRATCH}/lib/${f}"; done + for f in "${NATIVE_ARCHIVES[@]}" libsf.a libc.a; do echo stale > "${SCRATCH}/lib/${f}"; done : > "${SCRATCH}/include/sysio/native/sentinel.hpp" : > "${SCRATCH}/include/sysiolib/native/sentinel.hpp" @@ -350,8 +381,10 @@ else # libnative* and the native header trees only. libsf.a is asserted separately, and # positively: it is a WebAssembly archive built in every configuration. leftovers=() - for f in "${SCRATCH}/lib/libnative.a" "${SCRATCH}/lib/libnative_sysio.a" \ - "${SCRATCH}/include/sysio/native" "${SCRATCH}/include/sysiolib/native"; do + for f in "${NATIVE_ARCHIVES[@]}"; do + [ -e "${SCRATCH}/lib/${f}" ] && leftovers+=("${SCRATCH}/lib/${f}") + done + for f in "${SCRATCH}/include/sysio/native" "${SCRATCH}/include/sysiolib/native"; do [ -e "$f" ] && leftovers+=("$f") done if [ "${#leftovers[@]}" -eq 0 ]; then diff --git a/tools/abidiff/cdt-abidiff.cpp.in b/tools/abidiff/cdt-abidiff.cpp.in index 48a43da4c..f60d804de 100644 --- a/tools/abidiff/cdt-abidiff.cpp.in +++ b/tools/abidiff/cdt-abidiff.cpp.in @@ -81,6 +81,26 @@ inline std::string first_duplicate_member(const std::string& text) { return det.found() ? det.duplicate() : std::string{}; } +namespace { + /// The one ABI member whose value may itself be a JSON document: a FileDescriptorSet + /// written either as an object, or as a string containing that object's JSON. + constexpr auto protobuf_types_key = "protobuf_types"; + + /// Report a document whose comparison would be unfaithful, and exit non-zero. + /// + /// @param which the document as named on the command line -- "file1" or "file2". + /// @param dup the member name that appears more than once. + /// @param where where it appears, empty for the document itself. + [[noreturn]] void refuse_duplicate(const char* which, const std::string& dup, + const std::string& where) { + std::cerr << "Error: " << which << " has a duplicate object member '" << dup << "'" + << where << ". jsoncons keeps only the last, while the chain preserves both and\n" + "protobuf merges duplicate repeated fields -- so any comparison here\n" + "would describe a value the runtime never sees.\n"; + exit(1); + } +} + class abidiff { private: ojson abi_1, abi_2; @@ -93,14 +113,28 @@ class abidiff { buf << in.rdbuf(); const std::string text = buf.str(); const std::string dup = first_duplicate_member(text); - if (!dup.empty()) { - std::cerr << "Error: " << which << " has a duplicate object member '" << dup - << "'. jsoncons keeps only the last, while the chain preserves both and\n" - "protobuf merges duplicate repeated fields -- so any comparison here\n" - "would describe a value the runtime never sees.\n"; - exit(1); + if (!dup.empty()) + refuse_duplicate(which, dup, ""); + + ojson parsed = ojson::parse(text); + + // ...and again one level down, inside protobuf_types when it is written as a STRING. + // The walk above sees that spelling as a single opaque string value, so a descriptor + // set carrying two `file` members passes it -- and canonical_protobuf then parses the + // string with jsoncons, which keeps only the last. The document compares EQUAL to one + // carrying just that member, while JsonStringToMessage reads the original as both + // descriptors: the same false negative the outer check exists to prevent, in the one + // place the outer check cannot see. Refused here rather than at the comparison, so a + // document is rejected on the same terms whichever spelling it uses. + if (parsed.is_object() && parsed.count(protobuf_types_key)) { + const ojson& pb = parsed.at(protobuf_types_key); + if (pb.is_string()) { + const std::string nested = first_duplicate_member(pb.as()); + if (!nested.empty()) + refuse_duplicate(which, nested, " inside protobuf_types"); + } } - return ojson::parse(text); + return parsed; } public: abidiff( const std::string& fn1, const std::string& fn2) { @@ -480,11 +514,12 @@ class abidiff { // So it over-reports rather than under-reports, with ONE exception it cannot resolve // here: jsoncons keeps only the last of a duplicate object member, which would hide a // real difference. Such a document is refused at load (see load_checked) rather than - // compared. + // compared -- in EITHER spelling, since the string form's own members are invisible to + // a walk of the outer document and are checked separately there. static ojson canonical_protobuf(const ojson& o) { - if (!o.count("protobuf_types")) + if (!o.count(protobuf_types_key)) return ojson::null(); - const ojson& v = o.at("protobuf_types"); + const ojson& v = o.at(protobuf_types_key); if (!v.is_string()) return v; // already an object (or something else; as-is) const std::string text = v.as(); From 482e2edbbafcac38e935c3ceab788ddcc686701d Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Thu, 3 Sep 2026 14:50:28 -0500 Subject: [PATCH 21/26] chore: refuse a nested duplicate only where the value is actually adopted The nested check added in the previous commit refused on any duplicate in a protobuf_types string, which is stricter than the comparison it protects. canonical_protobuf adopts the parsed value only for an OBJECT root; any other root is returned as the string node and compared verbatim, so jsoncons drops nothing from the compared value and the refusal's own reason does not hold. A document spelling protobuf_types as "[{\"a\":1,\"a\":2}]" diffed faithfully before and was refused after, with a diagnostic that misstated why. The gate asks canonical_protobuf whether it would adopt, rather than restating the rule, so the two cannot drift apart again. The regression that shipped with it compared a duplicate-free string against its object counterpart, which the pre-existing assertion four lines up already covered. Replaced with the case that earns its place: a duplicate inside a non-object-root string still compares, and reports the difference. Removing the gate fails it. --- tests/unit/abidiff_tests.sh | 14 ++++++++++---- tools/abidiff/cdt-abidiff.cpp.in | 9 ++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index 9198d9e2a..ca7c72b5a 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -582,10 +582,16 @@ printf '{%s,"protobuf_types":"{\\"file\\":[{\\"name\\":\\"a.proto\\"}],\\"file\\ expect_refused "a duplicate member inside a protobuf_types string is refused" \ "${WORK}/pb_dup_str.abi" "${WORK}/pb_onlyb.abi" -# ...and refusing the string form must not come from refusing every string: the well-formed -# string spelling above still compares, and equal to its object counterpart. -expect_quiet "a duplicate-free protobuf_types string still compares" \ - "${WORK}/pb_str.abi" "${WORK}/pb_obj.abi" "protobuf_types" +# ...but only for the spelling canonical_protobuf ADOPTS. A string whose JSON root is not an +# object is compared verbatim, as the string it is -- the assertion above pins that -- so +# jsoncons drops nothing from the compared value and this refusal's reason does not hold. +# Refusing it would be stricter than the object spelling rather than equal to it, and would +# take away a document that diffs faithfully. (The chain rejects such content either way, +# JsonStringToMessage needing a message root, which is exactly why it stays a verbatim string.) +printf '{%s,"protobuf_types":"[{\\"a\\":1,\\"a\\":2}]"}\n' "$PB_BASE" > "${WORK}/pb_arrdup.abi" +printf '{%s,"protobuf_types":"[{\\"a\\":9}]"}\n' "$PB_BASE" > "${WORK}/pb_arrother.abi" +expect_reports "a duplicate in a non-object-root protobuf_types string still compares" \ + "${WORK}/pb_arrdup.abi" "${WORK}/pb_arrother.abi" "protobuf_types" echo "" echo "Results: ${PASS} passed, ${FAIL} failed" diff --git a/tools/abidiff/cdt-abidiff.cpp.in b/tools/abidiff/cdt-abidiff.cpp.in index f60d804de..72b0a7bfc 100644 --- a/tools/abidiff/cdt-abidiff.cpp.in +++ b/tools/abidiff/cdt-abidiff.cpp.in @@ -126,9 +126,16 @@ class abidiff { // descriptors: the same false negative the outer check exists to prevent, in the one // place the outer check cannot see. Refused here rather than at the comparison, so a // document is rejected on the same terms whichever spelling it uses. + // + // ONLY for the spelling canonical_protobuf actually ADOPTS. A string whose JSON root + // is not an object is compared verbatim, as the string it is, so nothing is dropped + // from the compared value and this refusal's own reason would not hold -- refusing it + // would be stricter than the object spelling rather than equal to it, and would take + // away a document that diffs faithfully today. The adoption rule is asked for rather + // than restated, so the two cannot drift apart. if (parsed.is_object() && parsed.count(protobuf_types_key)) { const ojson& pb = parsed.at(protobuf_types_key); - if (pb.is_string()) { + if (pb.is_string() && canonical_protobuf(parsed).is_object()) { const std::string nested = first_duplicate_member(pb.as()); if (!nested.empty()) refuse_duplicate(which, nested, " inside protobuf_types"); From 511b1ad27d3858827df85f60b9ccb8372874e7ec Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 4 Sep 2026 07:25:14 -0500 Subject: [PATCH 22/26] chore: parse ABIs strictly, and count only the non-native sysiolib headers Two findings from review. jsoncons's default parse handler silently accepts and DISCARDS C/C++ comments -- default_parse_error_handler swallows illegal_comment alone -- while fc rejects them. A commented document is therefore one the chain will not load, and parsing it leniently made it compare EQUAL to the uncommented document the chain does load: the difference gone before anything compared it, exactly like a dropped duplicate member. The same false equality reached the string spelling of protobuf_types, where the chain's verdict actually flips between the two -- protobuf rejects a commented string and accepts the equivalent object. Both parses now use strict_parse_error_handler. The outer one refuses the document with a diagnostic naming the file; the nested one in canonical_protobuf simply does not adopt, so a commented string stays the string it is and differs from the object. Three regressions cover it, including that an uncommented string still equals its object. The non-native destination count was recursive, and include/sysiolib/native nests inside include/sysiolib -- so four native headers satisfied an assertion about the 63 regular ones. Gating the main sysiolib copy on NOT STAGE_NATIVE dropped every regular header from a native-ON build with the suite still green. The count now prunes that subtree. Excluding it is necessary but not sufficient: nothing re-ran the staging script in ON mode, so the live include tree stayed a snapshot of the last real build and the regression was invisible until someone rebuilt. The isolated fresh staging, which already runs the script in the configured mode, is now checked per destination too. Each half is needed -- with either alone the fault injection still passes. --- tests/unit/abidiff_tests.sh | 34 ++++++++++++++++++++++++++---- tests/unit/staged_headers_tests.sh | 20 +++++++++++++++++- tools/abidiff/cdt-abidiff.cpp.in | 31 ++++++++++++++++++++++++--- 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index ca7c72b5a..cd20fb49e 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -219,13 +219,13 @@ expect_reports() { # $1=desc $2=a $3=b $4=needle # diagnostic naming the repeated member -- not compared. Shared, because both spellings of # protobuf_types reach it and an inline copy per spelling is how one of them stops being # checked at all. -expect_refused() { # $1=desc $2=a $3=b +expect_refused() { # $1=desc $2=a $3=b $4=needle local out if out="$(run_abidiff "$2" "$3" 2>&1)"; then fail "$1" echo " compared cleanly instead of refusing" sed 's/^/ /' <<< "$out" - elif grep -q "duplicate object member" <<< "$out"; then + elif grep -q "$4" <<< "$out"; then pass "$1" else fail "$1" @@ -569,7 +569,7 @@ printf '{%s,"protobuf_types":{"file":[{"name":"a.proto"}],"file":[{"name":"b.pro printf '{%s,"protobuf_types":"{\\"file\\":[{\\"name\\":\\"b.proto\\"}]}"}\n' \ "$PB_BASE" > "${WORK}/pb_onlyb.abi" expect_refused "a document with duplicate object members is refused" \ - "${WORK}/pb_dup.abi" "${WORK}/pb_onlyb.abi" + "${WORK}/pb_dup.abi" "${WORK}/pb_onlyb.abi" "duplicate object member" # The same duplicate one level down, inside the STRING spelling -- where the outer walk sees # only an opaque string value. This passed the check, was then parsed by jsoncons (which keeps @@ -580,7 +580,7 @@ expect_refused "a document with duplicate object members is refused" \ printf '{%s,"protobuf_types":"{\\"file\\":[{\\"name\\":\\"a.proto\\"}],\\"file\\":[{\\"name\\":\\"b.proto\\"}]}"}\n' \ "$PB_BASE" > "${WORK}/pb_dup_str.abi" expect_refused "a duplicate member inside a protobuf_types string is refused" \ - "${WORK}/pb_dup_str.abi" "${WORK}/pb_onlyb.abi" + "${WORK}/pb_dup_str.abi" "${WORK}/pb_onlyb.abi" "duplicate object member" # ...but only for the spelling canonical_protobuf ADOPTS. A string whose JSON root is not an # object is compared verbatim, as the string it is -- the assertion above pins that -- so @@ -593,6 +593,32 @@ printf '{%s,"protobuf_types":"[{\\"a\\":9}]"}\n' "$PB_BASE" > "${WO expect_reports "a duplicate in a non-object-root protobuf_types string still compares" \ "${WORK}/pb_arrdup.abi" "${WORK}/pb_arrother.abi" "protobuf_types" +# --- strict JSON ---------------------------------------------------------------------------- +# +# jsoncons's default handler silently accepts and DISCARDS C/C++ comments; fc rejects them, so +# a commented document is one the chain will not load. Parsing leniently made it compare equal +# to the uncommented document the chain does load -- the difference gone before anything +# compared it, exactly like a dropped duplicate member. +printf '{%s,"protobuf_types":{"file":[]} /* a comment fc rejects */ }\n' "$PB_BASE" \ + > "${WORK}/commented.abi" +printf '{%s,"protobuf_types":{"file":[]}}\n' "$PB_BASE" > "${WORK}/uncommented.abi" +expect_refused "a commented document is refused, not read as equal" \ + "${WORK}/commented.abi" "${WORK}/uncommented.abi" "not strict JSON" + +# ...and inside the string spelling, where the chain'"'"'s verdict flips between the two: protobuf +# rejects the commented string and accepts the equivalent object, so they are not the same ABI. +# canonical_protobuf adopts a string only when it parses STRICTLY, so the commented one stays +# the string it is and differs from the object. +printf '{%s,"protobuf_types":"{\\"file\\":[]/*c*/}"}\n' "$PB_BASE" > "${WORK}/pb_strcomment.abi" +printf '{%s,"protobuf_types":{"file":[]}}\n' "$PB_BASE" > "${WORK}/pb_fileobj.abi" +expect_reports "a commented protobuf_types string differs from the equivalent object" \ + "${WORK}/pb_strcomment.abi" "${WORK}/pb_fileobj.abi" "protobuf_types" + +# ...while the uncommented string and that object remain the same ABI, as before. +printf '{%s,"protobuf_types":"{\\"file\\":[]}"}\n' "$PB_BASE" > "${WORK}/pb_strplain.abi" +expect_quiet "an uncommented protobuf_types string still equals the object" \ + "${WORK}/pb_strplain.abi" "${WORK}/pb_fileobj.abi" "protobuf_types" + echo "" echo "Results: ${PASS} passed, ${FAIL} failed" [ "$FAIL" -eq 0 ] diff --git a/tests/unit/staged_headers_tests.sh b/tests/unit/staged_headers_tests.sh index 3faf91acd..76e45bead 100755 --- a/tests/unit/staged_headers_tests.sh +++ b/tests/unit/staged_headers_tests.sh @@ -120,6 +120,16 @@ readonly NON_NATIVE_DESTS=(sysiolib libc libcxx boost/preprocessor bluegrass) # what a hand-written character class drops. readonly NATIVE_ARCHIVES=(libnative.a libnative_sysio.a libnative_c.a libnative_c++.a libnative_rt.a) +# Count the files under a NON-NATIVE destination, with the native subtree that nests inside +# one of them excluded. Staging copies native/native into include/sysiolib/native, so a plain +# recursive count of include/sysiolib is satisfied by the four native headers alone: gating the +# main sysiolib copy on `NOT STAGE_NATIVE` dropped all 63 regular headers from a native-ON +# build and every assertion stayed green. +count_non_native_files() { + [ -d "$1" ] || { echo 0; return 0; } + find "$1" -type f -not -path '*/sysiolib/native/*' 2>/dev/null | wc -l +} + # Require each of those to be present AND non-empty under the include root $1, suffixing each # result with $2. The file count, not the directory: a staging regression that created the # destinations and copied nothing would ship a package with no public headers at all while a @@ -127,7 +137,7 @@ readonly NATIVE_ARCHIVES=(libnative.a libnative_sysio.a libnative_c.a libnative_ require_non_native_dests() { # $1=include root $2=label suffix local root="$1" label="$2" dest n for dest in "${NON_NATIVE_DESTS[@]}"; do - n="$(count_files "${root}/${dest}")" + n="$(count_non_native_files "${root}/${dest}")" if [ "$n" -gt 0 ]; then pass "${dest} is staged${label} (${n} files)" else @@ -164,6 +174,14 @@ else fail "the staging script populates a fresh tree" sed 's/^/ /' "${PRUNE_SCRATCH}/stage.log" else + # What the script produces IN THIS MODE, before any sentinel is planted. The live tree + # checked above is a snapshot of whatever the last build left, so a staging regression + # is invisible there until someone rebuilds; this runs the script and looks at its + # actual output. Gating the main sysiolib copy on `NOT STAGE_NATIVE` -- which drops all + # 63 regular headers from a native-ON build and leaves only the four under + # sysiolib/native -- is caught here and nowhere else. + require_non_native_dests "${PRUNE_SCRATCH}/include" " (fresh staging)" + planted=0 for dest in "${NON_NATIVE_DESTS[@]}"; do if [ -d "${PRUNE_SCRATCH}/include/${dest}" ]; then diff --git a/tools/abidiff/cdt-abidiff.cpp.in b/tools/abidiff/cdt-abidiff.cpp.in index 72b0a7bfc..2b35bec79 100644 --- a/tools/abidiff/cdt-abidiff.cpp.in +++ b/tools/abidiff/cdt-abidiff.cpp.in @@ -86,6 +86,21 @@ namespace { /// written either as an object, or as a string containing that object's JSON. constexpr auto protobuf_types_key = "protobuf_types"; + /// Parse JSON the way the runtime does: rejecting what it rejects. + /// + /// jsoncons's DEFAULT handler silently accepts and discards C/C++ comments -- see + /// `default_parse_error_handler`, which swallows `illegal_comment` alone. fc does not, so a + /// commented document is one the chain refuses to load, and parsing it leniently made it + /// compare EQUAL to the uncommented document the chain accepts. The same class of false + /// equality as a dropped duplicate member, and invisible for the same reason: the difference + /// is gone before anything compares it. + /// + /// @throws jsoncons::parse_error on a comment, or on any other malformed input. + inline ojson parse_strict(const std::string& text) { + jsoncons::strict_parse_error_handler strict; + return ojson::parse(text, strict); + } + /// Report a document whose comparison would be unfaithful, and exit non-zero. /// /// @param which the document as named on the command line -- "file1" or "file2". @@ -116,7 +131,15 @@ class abidiff { if (!dup.empty()) refuse_duplicate(which, dup, ""); - ojson parsed = ojson::parse(text); + ojson parsed; + try { + parsed = parse_strict(text); + } catch (const std::exception& e) { + std::cerr << "Error: " << which << " is not strict JSON: " << e.what() << "\n" + << "The chain's parser rejects it, so any comparison here would describe\n" + "a document that cannot be loaded.\n"; + exit(1); + } // ...and again one level down, inside protobuf_types when it is written as a STRING. // The walk above sees that spelling as a single opaque string value, so a descriptor @@ -533,7 +556,7 @@ class abidiff { if (text.empty()) return ojson::null(); // empty string == absent, per to_variant try { - ojson parsed = ojson::parse(text); + ojson parsed = parse_strict(text); // ONLY an object root. JsonStringToMessage requires a message, so a string // holding "null" or "[1,2]" is content the chain rejects -- treating it as the // value it decodes to would have equated it with absence, or with a raw array @@ -542,7 +565,9 @@ class abidiff { if (parsed.is_object()) return parsed; } catch (const std::exception&) { - // not valid JSON at all + // Not valid JSON, or not STRICT JSON. A string carrying a comment stays the string + // it is, and so differs from the object spelling -- which is what the chain sees, + // protobuf's JSON parser rejecting the comment and accepting the object. } return v; } From 97756f94ed3e7f94ef1f34641873e061cebf7cea Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 4 Sep 2026 09:03:03 -0500 Subject: [PATCH 23/26] chore: state strictness as this tool's policy, not as a claim about the chain The diagnostic added with strict parsing said "The chain's parser rejects it" for every error strict jsoncons raises. That is false for at least one of them: fc's parser consumes any comma it meets -- libfc/src/io/json.cpp, whose array loop is `if (in.peek() == ',') { in.get(); continue; }` -- so it loads a trailing comma happily while cdt-abidiff refused the file and blamed the chain. Refusing is still right, but as this tool's own policy: a lenient parse normalises the document it then compares, and answering "equal" about text it had to rewrite to read is the failure mode the strict parse exists to prevent. Refusing is the conservative direction, and it costs nothing that was working -- jsoncons's default handler already propagated every error but the comment, so a build from before this PR rejects a trailing comma identically. Only the comment case changed behaviour. There is also no single parser to mirror even if that were wanted: the outer document is fc's to read, a string-valued protobuf_types is JsonStringToMessage's, and their leniencies differ. One uniform policy is deliberate, and the comment on parse_strict now says so. Two regressions on the trailing comma: that it is refused, and that the diagnostic names whose rule it is. Restoring the old wording fails the second. --- tests/unit/abidiff_tests.sh | 13 ++++++++++++ tools/abidiff/cdt-abidiff.cpp.in | 34 ++++++++++++++++++++++---------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index cd20fb49e..519a294c0 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -605,6 +605,19 @@ printf '{%s,"protobuf_types":{"file":[]}}\n' "$PB_BASE" > "${WORK}/uncommented.a expect_refused "a commented document is refused, not read as equal" \ "${WORK}/commented.abi" "${WORK}/uncommented.abi" "not strict JSON" +# A trailing comma is refused too -- and NOT because the chain would refuse it. fc's parser +# consumes any comma it meets (libfc/src/io/json.cpp: the array loop `if (in.peek() == ',') +# { in.get(); continue; }`), so it loads one happily. Refusing is this tool's policy, and it +# predates strict parsing: jsoncons's default handler already propagated `extra_comma`, +# swallowing `illegal_comment` alone, so a lenient build rejects this identically. Pinned so +# the policy is on record as a decision rather than surviving as an accident, and so the +# diagnostic keeps saying whose rule it is. +printf '{%s,}\n' "$PB_BASE" > "${WORK}/trailing_comma.abi" +expect_refused "a trailing comma is refused as tool policy" \ + "${WORK}/trailing_comma.abi" "${WORK}/uncommented.abi" "not strict JSON" +expect_refused "...and the diagnostic does not claim the chain rejects it" \ + "${WORK}/trailing_comma.abi" "${WORK}/uncommented.abi" "this tool's policy" + # ...and inside the string spelling, where the chain'"'"'s verdict flips between the two: protobuf # rejects the commented string and accepts the equivalent object, so they are not the same ABI. # canonical_protobuf adopts a string only when it parses STRICTLY, so the commented one stays diff --git a/tools/abidiff/cdt-abidiff.cpp.in b/tools/abidiff/cdt-abidiff.cpp.in index 2b35bec79..ad2900f76 100644 --- a/tools/abidiff/cdt-abidiff.cpp.in +++ b/tools/abidiff/cdt-abidiff.cpp.in @@ -86,16 +86,27 @@ namespace { /// written either as an object, or as a string containing that object's JSON. constexpr auto protobuf_types_key = "protobuf_types"; - /// Parse JSON the way the runtime does: rejecting what it rejects. + /// Parse JSON strictly, refusing anything this tool would otherwise have to normalise. /// - /// jsoncons's DEFAULT handler silently accepts and discards C/C++ comments -- see - /// `default_parse_error_handler`, which swallows `illegal_comment` alone. fc does not, so a - /// commented document is one the chain refuses to load, and parsing it leniently made it - /// compare EQUAL to the uncommented document the chain accepts. The same class of false - /// equality as a dropped duplicate member, and invisible for the same reason: the difference - /// is gone before anything compares it. + /// The bug this closes is comments. jsoncons's DEFAULT handler silently accepts and DISCARDS + /// them -- see `default_parse_error_handler`, which swallows `illegal_comment` and nothing + /// else -- while fc rejects them outright. A commented document therefore compared EQUAL to + /// the uncommented one the chain accepts: the same class of false equality as a dropped + /// duplicate member, invisible for the same reason, the difference gone before anything + /// compared it. /// - /// @throws jsoncons::parse_error on a comment, or on any other malformed input. + /// Strictness beyond that is this TOOL'S POLICY and not a claim about the runtime, which is + /// laxer in places: fc's parser consumes any comma it meets (libfc/src/io/json.cpp), so it + /// loads a trailing one happily. Refusing to compare is the conservative direction -- it + /// never answers "equal" about a document it had to rewrite to read -- and it costs nothing + /// that was working, since the default handler already propagated every error but the + /// comment. Only the comment case changed behaviour here. + /// + /// There is also no single parser to mirror even if that were wanted: the outer document is + /// fc's to read, while a string-valued protobuf_types is JsonStringToMessage's, and their + /// leniencies are not the same. One uniform policy is deliberate. + /// + /// @throws jsoncons::parse_error on a comment, or on any other input strict JSON rejects. inline ojson parse_strict(const std::string& text) { jsoncons::strict_parse_error_handler strict; return ojson::parse(text, strict); @@ -136,8 +147,11 @@ class abidiff { parsed = parse_strict(text); } catch (const std::exception& e) { std::cerr << "Error: " << which << " is not strict JSON: " << e.what() << "\n" - << "The chain's parser rejects it, so any comparison here would describe\n" - "a document that cannot be loaded.\n"; + << "cdt-abidiff refuses what it cannot read strictly, rather than let a\n" + "lenient parse normalise a difference away before anything compares\n" + "it. The runtime's parsers are laxer in places -- fc tolerates a stray\n" + "comma -- so this is this tool's policy, not a verdict on whether the\n" + "chain would load the file.\n"; exit(1); } From 5f7d0c62046fba8df8352713bc7899c9670ce10d Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 4 Sep 2026 09:43:09 -0500 Subject: [PATCH 24/26] chore: correct comments that describe superseded implementations Documentation only; no behaviour change. Each claim was checked against the code rather than reworded. abi.hpp: supports_action_results said the abigen plugin, ABIMerger and cdt-abidiff all decide with it. Only abigen calls it -- ABIMerger reads the introduction constants directly, because it needs the (major, minor) pair itself to promote a merged version, and cdt-abidiff does not gate at all. supports_variants has no caller and now says so rather than implying three. cdt-abidiff.cpp.in: the get_version comment said "every gate below keys off this value" and the version comment said the prefix-insensitive parse is right for "deciding capability". There are no gates below any more -- every section is diffed unconditionally -- and what the parse is right for is ORDERING two versions. Both corrected, with the reason the gates went away stated where the parse is defined. tests/CMakeLists.txt and abidiff_tests.sh repeated the capability-gate framing in their preambles; the test file now records that both halves of the original bug are fixed, the parse and the gates. abimerge.hpp: the comment beside the version fixup said that when nothing forces a promotion "the newer document's own version STRING stands", so an inherited "eosio::abi/1.2" survives. merge_version canonicalises at the top of merge(), so what stands is already "sysio::abi/" -- ordering ignores the prefix so a foreign descriptor can be ingested, but the output never carries one. stage_cdt_tree.cmake and staged_headers_tests.sh both said an OFF configure never rebuilds libsf.a. It has been built in every configuration since the sf target moved outside the ENABLE_NATIVE_COMPILER guard in libraries/native/CMakeLists.txt, which is what the clean OFF probe asserts. CDTMacros.cmake.in cited db_store_i64 as the example unresolved intrinsic; this PR removes that surface, so it names kv_set/kv_get instead. Validated at this head with all three suites and ENABLE_INTEGRATION_TESTS=ON: ctest 31/31, the per-suite integration run 16/16, and 60/61/28 on the three focused shell suites. --- cmake/CDTMacros.cmake.in | 2 +- cmake/stage_cdt_tree.cmake | 6 +++--- tests/CMakeLists.txt | 4 ++-- tests/unit/abidiff_tests.sh | 15 +++++++++------ tests/unit/staged_headers_tests.sh | 7 ++++--- tools/abidiff/cdt-abidiff.cpp.in | 20 +++++++++++--------- tools/include/sysio/abi.hpp | 17 +++++++++++++---- tools/include/sysio/abimerge.hpp | 9 +++++---- 8 files changed, 48 insertions(+), 32 deletions(-) diff --git a/cmake/CDTMacros.cmake.in b/cmake/CDTMacros.cmake.in index b40ebbe50..ddb1ba275 100644 --- a/cmake/CDTMacros.cmake.in +++ b/cmake/CDTMacros.cmake.in @@ -182,7 +182,7 @@ endmacro() # ) # # The resulting module exports an `apply(uint64_t, uint64_t, uint64_t)` -# function. Intrinsic symbols (db_store_i64, etc.) are left undefined and +# function. Intrinsic symbols (kv_set, kv_get, etc.) are left undefined and # resolved at dlopen time against symbols exported by the host executable. function(add_native_contract) cmake_parse_arguments(ARG "" "TARGET;CONTRACT_CLASS;ABI_FILE" diff --git a/cmake/stage_cdt_tree.cmake b/cmake/stage_cdt_tree.cmake index d3cd68e06..7eff6eaf0 100644 --- a/cmake/stage_cdt_tree.cmake +++ b/cmake/stage_cdt_tree.cmake @@ -64,9 +64,9 @@ file(COPY "${STAGE_SOURCE_DIR}/sysiolib" # # libnative* ONLY. libsf.a is a WebAssembly archive, not a native-host one: cdt-ld links it # with -lsf for --use-rt and every --fquery mode (compiler_options.hpp.in), and the base -# install ships it. It merely happens to be declared in libraries/native/CMakeLists.txt, which -# is why an OFF configure does not rebuild it -- so deleting it here would strip a working -# copy from a reused tree and leave an OFF package unable to link those modes. +# install ships it. It is declared in libraries/native/CMakeLists.txt but OUTSIDE that file's +# native-only guard, so every configuration builds and stages it -- deleting it here would +# strip the copy an OFF package needs to link those modes. if(NOT STAGE_NATIVE) file(GLOB stale_native "${STAGE_BINARY_DIR}/lib/libnative*") if(stale_native) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 18fe310a0..ac1efd630 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -40,8 +40,8 @@ configure_file(${CMAKE_CURRENT_SOURCE_DIR}/unit/multidir_contract_tests.sh ${CMA add_test(NAME multidir_contract_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/multidir_contract_tests.sh "${CMAKE_BINARY_DIR}" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) set_property(TEST multidir_contract_tests PROPERTY LABELS unit_tests) -# cdt-abidiff ABI-version handling — the capability gates must use parsed components, -# not a fixed-width suffix read that mis-scores a two-digit minor. +# cdt-abidiff ABI-version handling — the version must be read from parsed components, not a +# fixed-width suffix read that mis-scores a two-digit minor. configure_file(${CMAKE_CURRENT_SOURCE_DIR}/unit/abidiff_tests.sh ${CMAKE_BINARY_DIR}/tests/unit/abidiff_tests.sh COPYONLY) add_test(NAME abidiff_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/abidiff_tests.sh "${CMAKE_BINARY_DIR}/bin" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) set_property(TEST abidiff_tests PROPERTY LABELS unit_tests) diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index 519a294c0..5f5f9e554 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -2,13 +2,16 @@ # Regression tests for cdt-abidiff's ABI version handling. # # get_version used to be `stod(ver.substr(ver.size() - 3)) * 10`, a fixed-width suffix -# read that returns 1 for "sysio::abi/1.10" (it sees ".10"). Both capability gates in -# diff() compared that against 11 and 12, so for any two-digit minor the variant and -# action-result diffs were silently skipped -- a real difference reported as none. The -# same suffix read also collapsed "eosio::abi/1.2" and "sysio::abi/1.2" to one number. +# read that returns 1 for "sysio::abi/1.10" (it sees ".10"). Version gates in diff() +# compared that against 11 and 12, so for any two-digit minor the variant and action-result +# diffs were silently skipped -- a real difference reported as none. The same suffix read +# also collapsed "eosio::abi/1.2" and "sysio::abi/1.2" to one number. # -# cdt-abidiff now shares abi_version::parse_version_string and the supports_* predicates -# with the rest of the toolchain, so the gates compare (major, minor) components. +# Both halves of that are fixed. The version is now read with the shared +# abi_version::parse_version_string, and the gates are gone entirely: every section is +# diffed unconditionally, so a section one document carries and the other does not is +# reported whatever version either side declares. The parse survives because the version +# string is itself compared, and because an unreadable one stops the run. # # Usage: abidiff_tests.sh set -euo pipefail diff --git a/tests/unit/staged_headers_tests.sh b/tests/unit/staged_headers_tests.sh index 76e45bead..38ec8b5c3 100755 --- a/tests/unit/staged_headers_tests.sh +++ b/tests/unit/staged_headers_tests.sh @@ -413,9 +413,10 @@ else fi # libsf.a must SURVIVE. It is the WebAssembly softfloat archive cdt-ld links with - # -lsf for --use-rt and the --fquery modes, not a native-host archive -- it is only - # declared under libraries/native/, which is why an OFF configure never rebuilds it. - # Pruning it would leave an OFF package unable to link those modes. + # -lsf for --use-rt and the --fquery modes, not a native-host archive. It is declared + # under libraries/native/ but outside that file's native-only guard, so every + # configuration builds it -- the clean OFF probe above asserts exactly that. Pruning + # it here would leave an OFF package unable to link those modes. if [ -e "${SCRATCH}/lib/libsf.a" ]; then pass "STAGE_NATIVE=0 keeps libsf.a (a wasm archive, not a native one)" else diff --git a/tools/abidiff/cdt-abidiff.cpp.in b/tools/abidiff/cdt-abidiff.cpp.in index ad2900f76..952ece37b 100644 --- a/tools/abidiff/cdt-abidiff.cpp.in +++ b/tools/abidiff/cdt-abidiff.cpp.in @@ -204,14 +204,16 @@ class abidiff { /// The (major, minor) an ABI declares. /// /// The previous form -- stod over the string's last three characters -- read - /// "sysio::abi/1.10" as ".10" -> 1, so both capability gates below silently - /// skipped the variant and action-result diffs for a two-digit minor. It also - /// collapsed eosio::abi/1.2 and sysio::abi/1.2 to the same number. - /// A failed parse is refused, not defaulted. Seeding the outputs with 1.2 and - /// ignoring the result meant an unparsable or unsupported version -- including - /// `sysio::abi/2.0`, which the shared parser rejects -- silently compared equal to - /// a 1.2 document. Every gate below keys off this value, so a version we cannot - /// read is a reason to stop rather than to guess. + /// "sysio::abi/1.10" as ".10" -> 1. Version-gated diffs then skipped the variant and + /// action-result sections for any two-digit minor, reporting a real difference as none. + /// Those gates are gone: every section is diffed unconditionally, since a section one + /// document carries and the other does not is precisely the difference this tool exists + /// to report. The parse remains because the version is itself compared, and because an + /// unreadable one is a reason to stop. + /// + /// A failed parse is refused, not defaulted. Seeding the outputs with 1.2 and ignoring + /// the result meant an unparsable or unsupported version -- including `sysio::abi/2.0`, + /// which the shared parser rejects -- silently compared equal to a 1.2 document. std::pair get_version(const ojson& abi, const std::string& which) { if (!abi.has_key("version")) { std::cerr << "cdt-abidiff: " << which << " has no \"version\" field\n"; @@ -229,7 +231,7 @@ class abidiff { } // The FULL string, not just the parsed components. parse_version_string ignores - // everything through the last '/', which is right for deciding capability and wrong + // everything through the last '/', which is right for ORDERING two versions and wrong // here: Wire's abi_serializer accepts only a "sysio::abi/1." prefix, so an otherwise // identical eosio::abi/1.2 is a deployment-relevant difference. The numeric parse still // runs, to reject an unsupported version. diff --git a/tools/include/sysio/abi.hpp b/tools/include/sysio/abi.hpp index b53565cd4..195c7370b 100644 --- a/tools/include/sysio/abi.hpp +++ b/tools/include/sysio/abi.hpp @@ -53,6 +53,10 @@ namespace abi_version { inline constexpr int action_results_minor = 2; /// Does a version carry the `variants` section? + /// + /// Provided for symmetry with `supports_action_results`; nothing calls it today, because + /// ABIMerger gates on the introduction pair directly and abigen emits `variants` + /// unconditionally. inline constexpr bool supports_variants(int major_v, int minor_v) { return major_v > variants_major || (major_v == variants_major && minor_v >= variants_minor); @@ -60,10 +64,15 @@ namespace abi_version { /// Does a version carry the `action_results` section? /// - /// One predicate for the whole toolchain: the abigen plugin decides with it - /// whether to emit the section, ABIMerger whether to merge it, and cdt-abidiff - /// whether to diff it. Three separate spellings of this rule is how a contract - /// ends up with a version stamp that promises a section its ABI does not carry. + /// Used by the abigen plugin to decide whether to emit the section. ABIMerger answers the + /// same question from the introduction constants above rather than through this predicate, + /// because it needs the (major, minor) pair itself to promote a merged document's version; + /// both therefore read the one rule declared here. Two spellings of that rule is how a + /// contract ends up with a version stamp promising a section its ABI does not carry. + /// + /// cdt-abidiff does NOT gate on it -- it diffs every section unconditionally, since a + /// section present in one document and absent from the other is exactly the difference it + /// exists to report, whatever version either side declares. inline constexpr bool supports_action_results(int major_v, int minor_v) { return major_v > action_results_major || (major_v == action_results_major && minor_v >= action_results_minor); diff --git a/tools/include/sysio/abimerge.hpp b/tools/include/sysio/abimerge.hpp index c92b86262..d586bd52e 100644 --- a/tools/include/sysio/abimerge.hpp +++ b/tools/include/sysio/abimerge.hpp @@ -97,11 +97,12 @@ class ABIMerger { emit_section("variants", std::move(variants_section), variants_since); emit_section("action_results", std::move(results_section), action_results_since); - // Corrected in place (keeping its leading position) only if emit_section raised the + // Rewritten in place (keeping its leading position) only if emit_section raised the // version above what either input declared. When nothing forced a promotion the - // newer document's own version STRING stands -- parse ignores the namespace prefix, - // so an inherited "eosio::abi/1.2" is valid and rewriting it to "sysio::abi/" would - // be a silent change to every merged document. + // string merge_version already stamped at the top of merge() stands -- which is + // canonical, not inherited: version ORDERING ignores the namespace prefix so a + // foreign descriptor can be ingested, but the output always carries "sysio::abi/", + // since Wire's abi_serializer accepts nothing else. if (merged_version != declared_version) ret["version"] = abi_version::version_string(merged_version.first, merged_version.second); { From 3cc7752c36e88fae68d9f2a97e361923f2ec55f7 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 4 Sep 2026 10:11:09 -0500 Subject: [PATCH 25/26] chore: correct the optional-field schema note in the source as well The previous commit fixed this claim in the PR description but left it in the two places that document the code: cdt-abidiff.cpp.in and the abidiff test preamble both said table_id, index_type and secondary_indexes are Wire extensions that a stock Antelope ABI carries none of. index_type, key_names and key_types are standard table_def fields; only table_id and secondary_indexes are Wire's additions. The t_antelope fixture ten lines below the test comment carries index_type, key_names and key_types and omits only the other two, so the comment contradicted the fixture it introduces. Both now say which two are the additions and point at that fixture as the evidence. While correcting it, the comment above section_or_empty turned out to be two comments: an optional-FIELD paragraph ending "every optional field is read through this", sitting on the function that reads whole SECTIONS. The field paragraph moves to field_or_null, where that sentence is true. --- tests/unit/abidiff_tests.sh | 12 +++++++----- tools/abidiff/cdt-abidiff.cpp.in | 17 ++++++++++------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/tests/unit/abidiff_tests.sh b/tests/unit/abidiff_tests.sh index 5f5f9e554..ad506ecc2 100755 --- a/tests/unit/abidiff_tests.sh +++ b/tests/unit/abidiff_tests.sh @@ -323,11 +323,13 @@ expect_quiet "an identical table with secondary indexes reports no difference" \ # --- optional keys --------------------------------------------------------------------- # -# table_id, index_type and secondary_indexes are Wire extensions: a stock Antelope/eosio-cdt -# ABI carries none of them. Reading an absent key through jsoncons' const operator[] throws, -# so comparing them naively aborted the tool (exit 255) on every such ABI -- including two -# byte-identical ones. capture() already fails a case whose process exits non-zero, so these -# assert the comparison happens at all, not merely that it is quiet. +# table_id and secondary_indexes are Wire's additions to table_def: a stock Antelope/eosio-cdt +# ABI omits those two. index_type, key_names and key_types are STANDARD table_def fields and +# are present there -- the t_antelope fixture below carries all three, which is what makes it +# a stock ABI rather than an empty one. Reading an absent key through jsoncons' const +# operator[] throws, so comparing them naively aborted the tool (exit 255) on every such ABI -- +# including two byte-identical ones. capture() already fails a case whose process exits +# non-zero, so these assert the comparison happens at all, not merely that it is quiet. cat > "${WORK}/t_antelope.abi" <<'EOF' { "version": "sysio::abi/1.2", diff --git a/tools/abidiff/cdt-abidiff.cpp.in b/tools/abidiff/cdt-abidiff.cpp.in index 952ece37b..a812dffad 100644 --- a/tools/abidiff/cdt-abidiff.cpp.in +++ b/tools/abidiff/cdt-abidiff.cpp.in @@ -251,13 +251,10 @@ class abidiff { std::cout << pretty_print(section_or_empty(abi, "structs").at(index)) << "\n"; } - // An absent key read through the CONST operator[] throws, and ABIs legitimately omit - // keys: table_id, index_type and secondary_indexes are Wire extensions, so a stock - // Antelope ABI carries none of them. Every optional field is read through this, so - // absent compares equal to absent and never equal to present. - // A whole section may be legitimately absent -- a stock Antelope 1.0/1.1 ABI omits - // `variants` and `action_results` -- so those are read through this. Absent reads as an - // empty array, which is what "this ABI declares none" means. + // An absent key read through the CONST operator[] throws, and a whole SECTION may be + // legitimately absent -- a stock Antelope 1.0/1.1 ABI omits `variants` and + // `action_results` -- so every section is read through this. Absent reads as an empty + // array, which is what "this ABI declares none" means. static const ojson& section_or_empty(const ojson& o, const char* key) { static const ojson none = ojson::array(); return o.count(key) ? o.at(key) : none; @@ -268,6 +265,12 @@ class abidiff { // identically -- reporting them as different would describe a difference the runtime does // not see. `version` is the exception, and get_version diagnoses a missing or unparsable // one. + // + // Individual FIELDS are optional too. `table_id` and `secondary_indexes` are Wire's + // additions to `table_def`, so a stock Antelope ABI omits them -- while `index_type`, + // `key_names` and `key_types` are standard and present there, as the t_antelope fixture + // in abidiff_tests.sh carries. Every optional field is read through this, so absent + // compares equal to absent and never equal to present. static const ojson& field_or_null(const ojson& o, const char* key) { static const ojson absent = ojson::null(); From 0729b548277b336459ca2f9fe9946afdbd596150 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 4 Sep 2026 10:35:59 -0500 Subject: [PATCH 26/26] chore: put the retired zero-major sentinel in past tense parse_version_string's doc said cdt-cpp reads a zero major as "the option was never given", which stopped being true when the driver's `> 0` guards went: it now records the parsed version in the manifest and forwards it to codegen unconditionally, and says so in its own comment two files away. The paragraph contradicted that, and described a fallback parse() itself makes unreachable. The rationale is kept, in past tense, because it is why the driver can be as simple as it is: the sentinel is retired precisely BECAUSE zero never survives the parse, so keeping the rejection is what stops it being needed again. tests/unit/abi_version_tests.sh:150 carried the same claim about its own 0.1 case and is corrected with it -- the case still guards the driver/codegen divergence, now by keeping the sentinel unnecessary rather than by feeding it. ctest 31/31 with integration on; abi_version_tests 61/61. --- tests/unit/abi_version_tests.sh | 6 ++++-- tools/include/sysio/abi.hpp | 15 ++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/unit/abi_version_tests.sh b/tests/unit/abi_version_tests.sh index ecb3a39d0..a33de9944 100755 --- a/tests/unit/abi_version_tests.sh +++ b/tests/unit/abi_version_tests.sh @@ -147,8 +147,10 @@ check "1.10 keeps action_results (ABIMerger no longer parses the suffix)" \ "${WORK}/v1.10.abi" \ '"action_results"' -# There is no ABI 0.x, and cdt-cpp reads a zero major as "option absent" -- accepting -# one would reopen the driver/codegen divergence. Must be rejected, not coerced. +# There is no ABI 0.x, so 0.1 must be rejected rather than coerced. cdt-cpp no longer treats +# a zero major as "option absent" -- it records and forwards the parsed version +# unconditionally -- and this rejection is what lets it do that, so the case still guards the +# driver/codegen divergence, now by keeping the sentinel unnecessary rather than by feeding it. # 2.0 and 10.2 are rejected rather than accepted: abigen's to_json only serializes # action_results when major == 1, so a higher major would be stamped onto an ABI missing # the sections that version implies, and the merger would rank it above 1.2 regardless. diff --git a/tools/include/sysio/abi.hpp b/tools/include/sysio/abi.hpp index 195c7370b..531893a5f 100644 --- a/tools/include/sysio/abi.hpp +++ b/tools/include/sysio/abi.hpp @@ -91,11 +91,16 @@ namespace abi_version { * expansion falls short in binary -- "1.3" parsed as minor 2 -- which silently * desynced the version handed to the plugin from the one handed to ABIMerger. * - * A zero major is rejected: there is no ABI 0.x, and cdt-cpp reads a zero major - * as "the option was never given" (tools/cc/cdt-cpp.cpp.in), so accepting one - * would let `cdt-cpp -abi-version 0.1` fall back to the default while - * `cdt-codegen --abi-version 0.1` honoured it -- reintroducing exactly the - * divergence this namespace exists to remove. + * A zero major is rejected: there is no ABI 0.x. + * + * That rule once carried a second job, worth recording because it is why the driver can + * be as simple as it now is. cdt-cpp USED to read a zero major as "the option was never + * given", so accepting one would have let `cdt-cpp -abi-version 0.1` fall back to the + * default while `cdt-codegen --abi-version 0.1` honoured it -- reintroducing exactly the + * divergence this namespace exists to remove. That sentinel is retired: the driver now + * records and forwards the parsed version unconditionally + * (tools/cc/cdt-cpp.cpp.in), which it can do precisely BECAUSE zero never survives this + * parse. Keeping the rejection is what stops the sentinel being needed again. * * A major above max_supported_major is rejected too. to_json only knows the 1.x * shape and gates action_results on major == 1, so a 2.0 or 10.2 request would