From 18aeb925debd98ab316ba160cfc30d443109a0bb Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Sun, 3 May 2026 01:49:31 -0400 Subject: [PATCH 01/10] ci: trigger on push to develop --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0681d5..af49b2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: workflow_dispatch: From e02f33125af724def476e006e3e83ebf16b8819f Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Sun, 3 May 2026 08:58:46 -0400 Subject: [PATCH 02/10] cmake: scope -freflection-latest with generator expression Use $<$:...> so the flag is decided at the consumer's build time rather than at our configure/install time. Without this, installing the package after a Clang configure would leak -freflection-latest into a downstream GCC consumer's build via the exported INTERFACE. --- CMakeLists.txt | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 5bbb81a..9a04a2a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,16 +17,21 @@ target_include_directories(reflect INTERFACE # configure error ("cxx_std_26 is not known to CXX compiler"). target_compile_options(reflect INTERFACE -std=c++26) -# Detect compiler and add reflection flags -if (CMAKE_CXX_COMPILER_ID MATCHES "Clang") - # Bloomberg Clang fork - target_compile_options(reflect INTERFACE -freflection-latest) -elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU") - # GCC 16+ — reflection is enabled automatically with -std=c++26 - if (CMAKE_CXX_COMPILER_VERSION VERSION_LESS "16.0") - message(WARNING "GCC < 16 does not support C++26 reflection. " - "Build from GCC trunk or use Bloomberg Clang fork.") - endif() +# Compiler-specific reflection flag. Wrapped in a generator expression +# so the decision is made at the *consumer's* build time, not when this +# package is configured/installed. Without this, installing after +# configuring with Clang would leak -freflection-latest into a +# downstream GCC consumer's build via the exported INTERFACE. +target_compile_options(reflect INTERFACE + $<$:-freflection-latest> +) + +# GCC 16+ enables reflection automatically with -std=c++26. This warning +# fires only at our configure time and is not exported to consumers. +if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" + AND CMAKE_CXX_COMPILER_VERSION VERSION_LESS "16.0") + message(WARNING "GCC < 16 does not support C++26 reflection. " + "Build from GCC trunk or use Bloomberg Clang fork.") endif() if (CMAKE_SOURCE_DIR STREQUAL PROJECT_SOURCE_DIR) From ac1aef60e2a38c7ca3d51f9f03254bc4aea7c7f8 Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Sun, 3 May 2026 08:59:06 -0400 Subject: [PATCH 03/10] ci: pin P2996 toolchain ref and tag image with toolchain SHA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin Dockerfile ARG P2996_REF to a specific upstream commit (currently 9ffb96e3ce362289008e14ad2a79a249f58aa90a — today's p2996 branch tip) so the toolchain stops drifting between rebuilds. build-image.yml now resolves the ref (from Dockerfile or workflow input) and tags the built image as both :latest (convenience) and :p2996- (immutable). Drops the previous :${{ github.sha }} tag, which was the reflect_cpp26 commit SHA — meaningless for tracking the toolchain version. CI still pulls :latest until a follow-up commit switches it to the pinned tag (which only exists after the next image rebuild). --- .github/workflows/build-image.yml | 22 ++++++++++++++++++++-- Dockerfile | 23 ++++++++++++++--------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index f978ce9..9d91759 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -53,6 +53,22 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # Resolve the P2996 ref we'll actually build. Default: read the + # pinned ARG P2996_REF from Dockerfile. Override: workflow input. + # The resolved SHA drives the image tag so CI can pin to a + # toolchain-versioned tag instead of :latest. + - name: Resolve P2996 ref + id: p2996 + run: | + if [ -n "${{ inputs.p2996_ref }}" ]; then + REF="${{ inputs.p2996_ref }}" + else + REF=$(grep -E '^ARG P2996_REF=' Dockerfile | head -1 | cut -d= -f2) + fi + echo "ref=$REF" >> "$GITHUB_OUTPUT" + echo "short=${REF:0:12}" >> "$GITHUB_OUTPUT" + echo "Building with P2996_REF=$REF (tag suffix: ${REF:0:12})" + - name: Build and push uses: docker/build-push-action@v5 with: @@ -62,7 +78,9 @@ jobs: # CI runners have ~7 GB RAM and 2 CPUs — default COMPILE_JOBS=2, # LINK_JOBS=1 fits comfortably. Override here if runners change. build-args: | - ${{ inputs.p2996_ref != '' && format('P2996_REF={0}', inputs.p2996_ref) || '' }} + P2996_REF=${{ steps.p2996.outputs.ref }} + # :latest floats and is convenient for ad-hoc pulls. + # :p2996- is the immutable tag CI pins to. tags: | ghcr.io/${{ github.repository_owner }}/clang-p2996:latest - ghcr.io/${{ github.repository_owner }}/clang-p2996:${{ github.sha }} + ghcr.io/${{ github.repository_owner }}/clang-p2996:p2996-${{ steps.p2996.outputs.short }} diff --git a/Dockerfile b/Dockerfile index 77bc3a4..d00c446 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,25 +41,30 @@ RUN apt-get update \ zlib1g-dev \ && rm -rf /var/lib/apt/lists/* -# Pin to a specific commit for reproducible builds. Update intentionally -# by changing this value (and recording why in the commit message). +# Pinned to a specific upstream commit for reproducible builds. Bumping +# this is a deliberate three-step change: +# 1. update the SHA below +# 2. rebuild via the "Build clang-p2996 image" workflow +# (it tags the image :p2996-) +# 3. update the matching tag in .github/workflows/ci.yml +# # To find the latest commit on the p2996 branch: # git ls-remote https://github.com/bloomberg/clang-p2996.git refs/heads/p2996 -ARG P2996_REF=p2996 +ARG P2996_REF=9ffb96e3ce362289008e14ad2a79a249f58aa90a ARG INSTALL_PREFIX=/opt/clang-p2996 ARG COMPILE_JOBS=2 ARG LINK_JOBS=1 WORKDIR /src -# Shallow clone of the p2996 branch — saves ~80% of clone time/disk vs full -# history. Then check out the specific commit if requested. +# Shallow-clone the p2996 branch tip (~80% smaller than full history), +# then fetch and check out the pinned commit. Direct shallow clone of an +# arbitrary SHA isn't supported by GitHub's smart-HTTP protocol, so the +# branch hop is necessary. RUN git clone --depth 1 --branch p2996 \ https://github.com/bloomberg/clang-p2996.git . \ - && if [ "${P2996_REF}" != "p2996" ]; then \ - git fetch --depth 1 origin "${P2996_REF}" \ - && git checkout "${P2996_REF}"; \ - fi + && git fetch --depth 1 origin "${P2996_REF}" \ + && git checkout "${P2996_REF}" # Configure: clang frontend only, libc++/libc++abi/libunwind runtimes, # x86_64 target only, Release mode. This trims the build by an order of From 24977ab7e4ad4e9b8e52e8da5d9be40c6bf69e64 Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Sun, 3 May 2026 12:05:20 -0400 Subject: [PATCH 04/10] ci: switch from :latest to pinned :p2996-9ffb96e3ce36 image tag CI now pulls an immutable, toolchain-versioned tag instead of the floating :latest tag, so test results can no longer change without a corresponding repo change. Image content is identical to the previous :latest (same upstream P2996 SHA, retagged via imagetools). --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af49b2b..2359e93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 - # Pull the prebuilt P2996 image from GHCR. To rebuild it, run the - # "Build clang-p2996 image" workflow manually (Actions tab). + # Pull the prebuilt P2996 image from GHCR. The :p2996- + # tag is immutable and must match the ARG P2996_REF pin in + # Dockerfile — bumping the P2996 ref is a coordinated change across + # both files plus a workflow-dispatched rebuild of the "Build + # clang-p2996 image" workflow. See Dockerfile for the full procedure. container: - image: ghcr.io/${{ github.repository_owner }}/clang-p2996:latest + image: ghcr.io/${{ github.repository_owner }}/clang-p2996:p2996-9ffb96e3ce36 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} From f5adad42e67bf7839b9b7dbc990f625898b17533 Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Sun, 3 May 2026 12:25:55 -0400 Subject: [PATCH 05/10] ci: bump actions/checkout v4 -> v5 (Node.js 24) --- .github/workflows/build-image.yml | 2 +- .github/workflows/ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 9d91759..f4bc87d 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 # GitHub-hosted runners ship with ~14 GB free; LLVM source + # build + image export easily exceeds that. Free ~25 GB by diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2359e93..f7e4313 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Configure run: | From 1d65979c770e95ec0bcb9f2dabdab5f67c061538 Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Sun, 3 May 2026 12:56:02 -0400 Subject: [PATCH 06/10] json: require owning std::string keys for json_map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-owning keys (std::string_view, const char*) silently dangled during deserialization — parse_string()'s temporary went out of scope while the view-typed key kept pointing into it. --- include/reflect/json.hpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/include/reflect/json.hpp b/include/reflect/json.hpp index 075dc16..122deb3 100644 --- a/include/reflect/json.hpp +++ b/include/reflect/json.hpp @@ -79,12 +79,18 @@ namespace json_detail { } // ---- type categories for JSON dispatch ---- + // json_map requires an *owning* std::string key. Accepting non-owning + // string-like keys (std::string_view, const char*) would silently + // dangle on deserialization — parse_string() returns a std::string + // by value, and emplacing it into a view-typed key stores a view + // into the temporary. Restricting the concept here keeps serialize + // and deserialize symmetric: both paths require owning keys. template concept json_map = requires(T t) { typename T::key_type; typename T::mapped_type; { t.begin() } -> std::input_iterator; - } && reflect::is_string_like; + } && std::same_as, std::string>; template concept json_array = reflect::is_range && !json_map; From de4b93da3b0ad90c2f0f1681be0bae2e73d668a2 Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Sun, 3 May 2026 13:17:50 -0400 Subject: [PATCH 07/10] address minor review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-build.sh: pass preset as positional arg to inner bash -c instead of interpolating into the command string. - args.hpp: add missing for std::declval (was relying on transitive include). - tuple.hpp: static_assert tuple size matches struct field count in from_tuple — previously a shorter tuple silently default-initialized the trailing fields. - test_critical_issues.cpp: drop reference to docs/notes/ note that isn't tracked. --- include/reflect/args.hpp | 1 + include/reflect/tuple.hpp | 8 ++++++++ scripts/docker-build.sh | 2 +- tests/test_critical_issues.cpp | 3 +-- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/include/reflect/args.hpp b/include/reflect/args.hpp index f33f14f..893a473 100644 --- a/include/reflect/args.hpp +++ b/include/reflect/args.hpp @@ -33,6 +33,7 @@ #include #include #include +#include #include namespace reflect { diff --git a/include/reflect/tuple.hpp b/include/reflect/tuple.hpp index 00bee8d..619f28d 100644 --- a/include/reflect/tuple.hpp +++ b/include/reflect/tuple.hpp @@ -80,6 +80,14 @@ constexpr auto to_ref_tuple(T& obj) { template constexpr T from_tuple(Tuple const& t) { + // Require exact size match. A shorter tuple would aggregate-initialize + // the leading fields and silently default the rest — surprising for + // a "↔" conversion. A longer tuple is already a hard error from the + // brace-init list, but we catch it here too with a clearer message. + static_assert( + std::tuple_size_v> + == detail::tuple_members_of(^^T).size(), + "from_tuple: tuple size must equal the struct's field count."); return [&](std::index_sequence) { return T{std::get(t)...}; }(std::make_index_sequence>>{}); diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh index a4ee8de..cec42a9 100755 --- a/scripts/docker-build.sh +++ b/scripts/docker-build.sh @@ -28,4 +28,4 @@ docker run --rm \ -v "$REPO_ROOT":/work \ -w /work \ "$IMAGE" \ - bash -c "cmake --preset $PRESET && cmake --build --preset $PRESET" + bash -c 'cmake --preset "$1" && cmake --build --preset "$1"' bash "$PRESET" diff --git a/tests/test_critical_issues.cpp b/tests/test_critical_issues.cpp index b7881ab..7a77f92 100644 --- a/tests/test_critical_issues.cpp +++ b/tests/test_critical_issues.cpp @@ -194,8 +194,7 @@ void c5_nan_serialization() { } // anonymous namespace int main() { - std::cout << "=== reflect — critical-issue regression test ===\n" - "See docs/notes/code-review-2026-04.md for context.\n"; + std::cout << "=== reflect — critical-issue regression test ===\n"; c1_reflectable_predicate(); c2_int_round_trip(); From 2f800852d185b9bb000c19f40c41cb17eb60091e Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Sun, 3 May 2026 13:36:23 -0400 Subject: [PATCH 08/10] add verification scaffolding from review follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verify-image-pin: pre-flight script + CI job that compares the Dockerfile P2996_REF pin against the image tag in ci.yml. Fails fast on host before paying for a stale-image pull. - install-smoke-test: CI job builds + installs reflect to a temp prefix, then builds a standalone consumer via find_package(reflect) and runs it. Catches export-contract bugs (e.g. compiler-specific INTERFACE flag leakage) that source-tree tests miss. - compile_fail_map_string_view: ctest entry that builds an intentionally ill-formed program (from_json of map) with WILL_FAIL TRUE — guards the json_map concept rejection. --- .github/workflows/ci.yml | 59 +++++++++++++++++++++++++ CMakeLists.txt | 21 +++++++++ scripts/verify-image-pin.sh | 19 ++++++++ tests/compile_fail_map_string_view.cpp | 20 +++++++++ tests/install-smoke-test/CMakeLists.txt | 12 +++++ tests/install-smoke-test/main.cpp | 31 +++++++++++++ 6 files changed, 162 insertions(+) create mode 100755 scripts/verify-image-pin.sh create mode 100644 tests/compile_fail_map_string_view.cpp create mode 100644 tests/install-smoke-test/CMakeLists.txt create mode 100644 tests/install-smoke-test/main.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7e4313..5e92b01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,22 @@ permissions: packages: read jobs: + # Cheap host-level pre-flight: ensure the image tag pulled by later + # jobs matches the upstream P2996 commit pinned in Dockerfile. Catches + # the human error of bumping one without the other. + verify-image-pin: + name: Verify CI image tag matches Dockerfile pin + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - uses: actions/checkout@v5 + - run: ./scripts/verify-image-pin.sh + build-and-test: name: Build & test (Bloomberg Clang P2996 fork) runs-on: ubuntu-latest timeout-minutes: 30 + needs: verify-image-pin # Pull the prebuilt P2996 image from GHCR. The :p2996- # tag is immutable and must match the ARG P2996_REF pin in @@ -48,3 +60,50 @@ jobs: - name: Run tests run: ctest --test-dir build --output-on-failure + + # Verify the installed package is consumable from a fresh build + # directory via find_package(reflect). Catches export-contract bugs + # (e.g. compiler-specific flags leaking through the INTERFACE) that + # in-source-tree tests miss because they don't go through install. + install-smoke-test: + name: Install + consumer smoke test + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: verify-image-pin + + container: + image: ghcr.io/${{ github.repository_owner }}/clang-p2996:p2996-9ffb96e3ce36 + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Build & install reflect to temp prefix + run: | + cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_CXX_COMPILER=clang++ \ + -DCMAKE_CXX_FLAGS="-stdlib=libc++" \ + -DCMAKE_EXE_LINKER_FLAGS="-stdlib=libc++" \ + -DCMAKE_INSTALL_PREFIX=/tmp/reflect-install \ + -DREFLECT_BUILD_TESTS=OFF \ + -DREFLECT_BUILD_EXAMPLES=OFF \ + -DREFLECT_BUILD_BENCHMARKS=OFF + cmake --build build --parallel + cmake --install build + + - name: Configure & build consumer against installed package + run: | + cmake -S tests/install-smoke-test -B build-consumer -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_CXX_COMPILER=clang++ \ + -DCMAKE_CXX_FLAGS="-stdlib=libc++" \ + -DCMAKE_EXE_LINKER_FLAGS="-stdlib=libc++" \ + -DCMAKE_PREFIX_PATH=/tmp/reflect-install + cmake --build build-consumer --parallel + + - name: Run consumer + run: ./build-consumer/consumer diff --git a/CMakeLists.txt b/CMakeLists.txt index 9a04a2a..dfeae1c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,6 +65,27 @@ if (REFLECT_BUILD_TESTS AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests") target_link_libraries(${test_target} PRIVATE reflect) add_test(NAME ${test_name} COMMAND ${test_target}) endforeach() + + # Compile-failure tests: each source listed here MUST fail to + # compile. The target is EXCLUDE_FROM_ALL (so the main build stays + # green) and ctest invokes cmake --build on it expecting non-zero + # exit (WILL_FAIL TRUE). A successful compile means the guard the + # test exercises has regressed. + set(REFLECT_COMPILE_FAIL_SOURCES + tests/compile_fail_map_string_view.cpp + ) + foreach(cf_src ${REFLECT_COMPILE_FAIL_SOURCES}) + get_filename_component(cf_name ${cf_src} NAME_WE) + set(cf_target reflect_${cf_name}) + add_executable(${cf_target} EXCLUDE_FROM_ALL ${cf_src}) + target_link_libraries(${cf_target} PRIVATE reflect) + add_test( + NAME ${cf_name} + COMMAND ${CMAKE_COMMAND} --build ${CMAKE_BINARY_DIR} + --target ${cf_target} --config $ + ) + set_tests_properties(${cf_name} PROPERTIES WILL_FAIL TRUE) + endforeach() endif() # Examples diff --git a/scripts/verify-image-pin.sh b/scripts/verify-image-pin.sh new file mode 100755 index 0000000..aee8717 --- /dev/null +++ b/scripts/verify-image-pin.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Verify that the clang-p2996 image tag pinned in .github/workflows/ci.yml +# matches the upstream P2996 ref pinned in Dockerfile. Catches the human +# error of bumping one without the other (drift between the two files). +set -euo pipefail + +DOCKERFILE_REF=$(grep -E '^ARG P2996_REF=' Dockerfile | head -1 | cut -d= -f2) +EXPECTED_TAG="p2996-${DOCKERFILE_REF:0:12}" +ACTUAL_TAG=$(grep -oE 'clang-p2996:[A-Za-z0-9._-]+' .github/workflows/ci.yml \ + | head -1 | cut -d: -f2) + +if [ "$EXPECTED_TAG" != "$ACTUAL_TAG" ]; then + echo "ERROR: ci.yml pulls clang-p2996:$ACTUAL_TAG" >&2 + echo " Dockerfile pins P2996_REF=$DOCKERFILE_REF" >&2 + echo " expected ci.yml tag: $EXPECTED_TAG" >&2 + exit 1 +fi + +echo "OK: ci.yml tag '$ACTUAL_TAG' matches Dockerfile P2996_REF '$DOCKERFILE_REF'" diff --git a/tests/compile_fail_map_string_view.cpp b/tests/compile_fail_map_string_view.cpp new file mode 100644 index 0000000..b6ecde7 --- /dev/null +++ b/tests/compile_fail_map_string_view.cpp @@ -0,0 +1,20 @@ +// Compile-failure test: from_json refuses to deserialize a map keyed +// by std::string_view (or any non-owning string type). This file MUST +// fail to compile — the corresponding CMake target is wired up with +// WILL_FAIL TRUE so a successful build of this file means the +// concept rejection is broken. +// +// See include/reflect/json.hpp — the json_map concept is constrained +// to std::string keys to prevent dangling views in the deserialized +// map. + +#include + +#include +#include + +int main() { + auto m = reflect::from_json>("{}"); + (void)m; + return 0; +} diff --git a/tests/install-smoke-test/CMakeLists.txt b/tests/install-smoke-test/CMakeLists.txt new file mode 100644 index 0000000..de17e6d --- /dev/null +++ b/tests/install-smoke-test/CMakeLists.txt @@ -0,0 +1,12 @@ +# Standalone consumer that pretends to be a downstream user of an +# installed reflect package. The CI install-smoke-test job builds and +# installs reflect to a temp prefix, then runs cmake on this directory +# pointing CMAKE_PREFIX_PATH at the install — exercising the real +# find_package(reflect) → reflect::reflect → INTERFACE flag flow. +cmake_minimum_required(VERSION 3.20) +project(reflect_consumer LANGUAGES CXX) + +find_package(reflect REQUIRED) + +add_executable(consumer main.cpp) +target_link_libraries(consumer PRIVATE reflect::reflect) diff --git a/tests/install-smoke-test/main.cpp b/tests/install-smoke-test/main.cpp new file mode 100644 index 0000000..250299a --- /dev/null +++ b/tests/install-smoke-test/main.cpp @@ -0,0 +1,31 @@ +// Smoke test: a downstream consumer that uses a couple of reflect +// features through the installed package. If any header fails to find +// its companions, or if -freflection-latest isn't reaching the consumer +// build, this won't compile or won't link. + +#include +#include + +#include +#include + +struct Point { int x; int y; }; + +int main() { + Point p{3, 4}; + + auto json = reflect::to_json(p); + auto pretty = reflect::to_string(p); + + if (json != R"({"x":3,"y":4})") { + std::cerr << "to_json mismatch: " << json << "\n"; + return 1; + } + if (pretty != "Point{.x=3, .y=4}") { + std::cerr << "to_string mismatch: " << pretty << "\n"; + return 1; + } + + std::cout << "install smoke test passed\n"; + return 0; +} From 3999d6953fdbffe62f179f5ddb5b0dc81a4074df Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Sun, 3 May 2026 13:58:40 -0400 Subject: [PATCH 09/10] address remaining minor review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - enum.hpp: guard flags_from_string against empty separator (would loop forever if enum_from_string ever returned a value for ""); regression test added. - verify-image-pin.sh: check every clang-p2996 tag in ci.yml, not just the first — multiple container jobs each carry the pin. - verify-tests-listed.sh: new pre-flight script that fails CI if any tests/test_*.cpp file is missing from REFLECT_TEST_SOURCES (or vice versa). The CMakeLists comment claimed missing entries surfaced as build failures; they actually silent-skip without this guard. - ci.yml: renamed the verification job to "preflight" and added the new test-list check as a second step. - test_critical_issues.cpp: drop the stale source comment that referenced the untracked docs/notes/ note. --- .github/workflows/ci.yml | 19 +++++++++++-------- CMakeLists.txt | 6 +++--- include/reflect/enum.hpp | 6 ++++++ scripts/verify-image-pin.sh | 26 ++++++++++++++++++++------ scripts/verify-tests-listed.sh | 28 ++++++++++++++++++++++++++++ tests/test_critical_issues.cpp | 11 +++++------ tests/test_enum.cpp | 3 +++ 7 files changed, 76 insertions(+), 23 deletions(-) create mode 100755 scripts/verify-tests-listed.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e92b01..223300f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,22 +15,25 @@ permissions: packages: read jobs: - # Cheap host-level pre-flight: ensure the image tag pulled by later - # jobs matches the upstream P2996 commit pinned in Dockerfile. Catches - # the human error of bumping one without the other. - verify-image-pin: - name: Verify CI image tag matches Dockerfile pin + # Cheap host-level pre-flight checks. Cumulatively run in seconds and + # gate the heavier container jobs so a misconfiguration fails fast on + # the host instead of after a slow image pull. + preflight: + name: Pre-flight checks runs-on: ubuntu-latest timeout-minutes: 2 steps: - uses: actions/checkout@v5 - - run: ./scripts/verify-image-pin.sh + - name: Verify CI image tag matches Dockerfile pin + run: ./scripts/verify-image-pin.sh + - name: Verify all test files are listed in CMakeLists.txt + run: ./scripts/verify-tests-listed.sh build-and-test: name: Build & test (Bloomberg Clang P2996 fork) runs-on: ubuntu-latest timeout-minutes: 30 - needs: verify-image-pin + needs: preflight # Pull the prebuilt P2996 image from GHCR. The :p2996- # tag is immutable and must match the ARG P2996_REF pin in @@ -69,7 +72,7 @@ jobs: name: Install + consumer smoke test runs-on: ubuntu-latest timeout-minutes: 10 - needs: verify-image-pin + needs: preflight container: image: ghcr.io/${{ github.repository_owner }}/clang-p2996:p2996-9ffb96e3ce36 diff --git a/CMakeLists.txt b/CMakeLists.txt index dfeae1c..218751b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,9 +44,9 @@ endif() # Listed explicitly rather than file(GLOB ...): a glob runs at configure # time, so a new test_xxx.cpp added on disk wouldn't be picked up until # CMake re-configures. With an explicit list, adding a test is a 20-second -# tax for guaranteed correctness — a missing entry here surfaces as a -# linker/test-discovery failure on the next build, not a silently-skipped -# test. +# tax for guaranteed determinism. The downside — a forgotten entry would +# silently skip the test rather than failing the build — is caught by +# scripts/verify-tests-listed.sh, run as a CI pre-flight step. set(REFLECT_TEST_SOURCES tests/test_args.cpp tests/test_critical_issues.cpp diff --git a/include/reflect/enum.hpp b/include/reflect/enum.hpp index 336d83e..e2097aa 100644 --- a/include/reflect/enum.hpp +++ b/include/reflect/enum.hpp @@ -243,6 +243,12 @@ template constexpr std::optional flags_from_string( std::string_view str, std::string_view separator = "|") { + // An empty separator would make str.find(separator) return 0 on + // every iteration, which combined with remove_prefix(0) would + // never advance — infinite loop. Reject it explicitly. + if (separator.empty()) + return std::nullopt; + using U = std::underlying_type_t; U combined = 0; diff --git a/scripts/verify-image-pin.sh b/scripts/verify-image-pin.sh index aee8717..bbb00fb 100755 --- a/scripts/verify-image-pin.sh +++ b/scripts/verify-image-pin.sh @@ -6,14 +6,28 @@ set -euo pipefail DOCKERFILE_REF=$(grep -E '^ARG P2996_REF=' Dockerfile | head -1 | cut -d= -f2) EXPECTED_TAG="p2996-${DOCKERFILE_REF:0:12}" -ACTUAL_TAG=$(grep -oE 'clang-p2996:[A-Za-z0-9._-]+' .github/workflows/ci.yml \ - | head -1 | cut -d: -f2) -if [ "$EXPECTED_TAG" != "$ACTUAL_TAG" ]; then - echo "ERROR: ci.yml pulls clang-p2996:$ACTUAL_TAG" >&2 +# Check every clang-p2996 tag reference in ci.yml — there are multiple +# container jobs, and one could drift while another stays correct. +count=0 +bad=0 +while IFS= read -r tag; do + count=$((count + 1)) + if [ "$tag" != "$EXPECTED_TAG" ]; then + echo "ERROR: ci.yml has clang-p2996:$tag" >&2 + bad=1 + fi +done < <(grep -oE 'clang-p2996:[A-Za-z0-9._-]+' .github/workflows/ci.yml | cut -d: -f2) + +if [ "$count" -eq 0 ]; then + echo "ERROR: no clang-p2996: references found in ci.yml" >&2 + exit 1 +fi + +if [ "$bad" -ne 0 ]; then echo " Dockerfile pins P2996_REF=$DOCKERFILE_REF" >&2 - echo " expected ci.yml tag: $EXPECTED_TAG" >&2 + echo " expected all ci.yml tags to be: $EXPECTED_TAG" >&2 exit 1 fi -echo "OK: ci.yml tag '$ACTUAL_TAG' matches Dockerfile P2996_REF '$DOCKERFILE_REF'" +echo "OK: $count ci.yml tag(s) all match Dockerfile P2996_REF '$DOCKERFILE_REF'" diff --git a/scripts/verify-tests-listed.sh b/scripts/verify-tests-listed.sh new file mode 100755 index 0000000..dddff55 --- /dev/null +++ b/scripts/verify-tests-listed.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Verify every tests/test_*.cpp file on disk is listed in +# CMakeLists.txt's REFLECT_TEST_SOURCES. Without this check, adding a +# new test file but forgetting to register it in CMake silently skips +# the test — the build stays green and ctest reports the same number +# of tests, just minus the new one. +set -euo pipefail + +ON_DISK=$(find tests -maxdepth 1 -name 'test_*.cpp' | sort) +LISTED=$(grep -oE 'tests/test_[A-Za-z0-9_]+\.cpp' CMakeLists.txt | sort -u) + +MISSING=$(comm -23 <(echo "$ON_DISK") <(echo "$LISTED")) +if [ -n "$MISSING" ]; then + echo "ERROR: test files on disk but not listed in CMakeLists.txt:" >&2 + echo "$MISSING" >&2 + echo "Add them to REFLECT_TEST_SOURCES." >&2 + exit 1 +fi + +# Inverse check: catch listed entries whose file no longer exists. +STALE=$(comm -13 <(echo "$ON_DISK") <(echo "$LISTED")) +if [ -n "$STALE" ]; then + echo "ERROR: CMakeLists.txt lists test files that don't exist:" >&2 + echo "$STALE" >&2 + exit 1 +fi + +echo "OK: all test files are registered in CMakeLists.txt." diff --git a/tests/test_critical_issues.cpp b/tests/test_critical_issues.cpp index 7a77f92..fc09238 100644 --- a/tests/test_critical_issues.cpp +++ b/tests/test_critical_issues.cpp @@ -1,9 +1,8 @@ -// tests/test_critical_issues.cpp — regression test for the issues -// described in docs/notes/code-review-2026-04.md. Once the fixes land, -// every section in this file should succeed; if any one regresses, the -// section reports the observed wrong value alongside the expected one. -// -// Built and run via ctest (registered by CMakeLists.txt's tests glob). +// tests/test_critical_issues.cpp — regression suite for past critical +// bugs: parsing edge cases, comparison/hash divergence, depth limits, +// NaN serialization, etc. Each section asserts the current behavior; +// if a fix regresses, the section prints the observed wrong value +// alongside the expected one. #include diff --git a/tests/test_enum.cpp b/tests/test_enum.cpp index 9f27fd3..af87dbe 100644 --- a/tests/test_enum.cpp +++ b/tests/test_enum.cpp @@ -155,6 +155,9 @@ void test_flags() { // Invalid flag assert(reflect::flags_from_string("read|bogus") == std::nullopt); + // Empty separator: would otherwise loop forever. Must reject up front. + assert(reflect::flags_from_string("read|write", "") == std::nullopt); + std::cout << " flags: PASS\n"; } From 824edb658cc059529c578bd3bbeab442d5bc4bfe Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Sun, 3 May 2026 14:09:54 -0400 Subject: [PATCH 10/10] Release v0.2.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-up release. No new features — hardening, reproducibility, and a couple of latent-bug fixes. CMake / install - Wrap -freflection-latest in a generator expression so the flag is only injected for Clang consumers, not exported to GCC consumers via the installed INTERFACE. - args.hpp: add missing for std::declval (was relying on a transitive include). Library correctness - json: tighten json_map concept to require std::string keys; the previous concept admitted non-owning keys (std::string_view, const char*) that would silently dangle after deserialization. - tuple: from_tuple now static_asserts that tuple size matches the struct's field count; a shorter tuple previously default-init'd the trailing fields silently. - enum: flags_from_string rejects an empty separator instead of potentially looping forever. Toolchain reproducibility - Pin Dockerfile ARG P2996_REF to a specific upstream SHA; tag the image as :p2996- in build-image.yml. - ci.yml pulls the immutable :p2996- tag instead of :latest; pre-flight script verifies the two pins stay in sync. - Bump actions/checkout v4 -> v5 (Node 24). CI verification scaffolding - Pre-flight checks job (image-pin sync + test-list completeness). - Install + consumer smoke test job (find_package(reflect) + target_link_libraries(reflect::reflect) end-to-end). - Compile-failure ctest entry guarding the json_map concept rejection. Cleanup - docker-build.sh: pass preset as a positional arg instead of interpolating into bash -c. - test_critical_issues.cpp: drop dead reference to docs/notes/ note. --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 218751b..c246dce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.20) project(reflect - VERSION 0.2.0 + VERSION 0.2.1 DESCRIPTION "C++26 reflection utilities — header-only, zero dependencies" LANGUAGES CXX )