Skip to content

Scrap your static_assert: report compile-time checks instead of breaking the build - #89

Open
steve-downey wants to merge 6 commits into
bemanproject:mainfrom
steve-downey:test/scrap-static-assert
Open

Scrap your static_assert: report compile-time checks instead of breaking the build#89
steve-downey wants to merge 6 commits into
bemanproject:mainfrom
steve-downey:test/scrap-static-assert

Conversation

@steve-downey

Copy link
Copy Markdown
Member

Removes every live static_assert from tests/beman/expected/*.test.cpp, replacing each with a check the test run reports.

Why

A static_assert is a poor test. A wrong answer is a translation failure, so the only thing anyone sees is a compiler diagnostic — the test run reports nothing, the xUnit output is empty, and because the build stops at the first failing assertion, no other test in the file is exercised. A trait that flips from under you takes the whole file with it and tells you about exactly one problem.

What replaces it

Two header-only components under tests/beman/expected/testing/:

constant_eval(probe) is consteval, so calling it is an immediate invocation: the probe is evaluated during translation and its result must be a constant expression. Whether something can be constant-evaluated is therefore still answered by the compiler, with no static_assert needed — only the value comparison moves to the test run:

constexpr auto probe = [] {
    expt::expected<int, int> e(expt::unexpect, 7);
    return int_state{e.has_value(), e.error()};
};
CHECK(constant_eval(probe) == int_state{false, 7});   // constant evaluation
CHECK(probe() == int_state{false, 7});                // ordinary evaluation

A wrong value now reports { error 7 } == { error 3 } and the rest of the file still runs. Because the probe is a plain lambda rather than a consteval one, the same body covers both evaluation modes — which matters for a union-based type.

type_name<T>() returns the identity of T, compared with std::is_same_v. The check is exactly as strict as the static_assert it replaces; the compiler's spelling is consulted only to explain a failure:

FAILED: CHECK( type_name<decltype(*e)>() == type_name<int&>() )
with expansion: const int& == int&

It has two implementations of that spelling, selected automatically and holding to the same contract: P2996 reflection via std::meta::display_string_of(^^T) where available, and otherwise recovery from std::source_location::function_name() by calibrating on the signatures of signature<bool> and signature<char>, which differ only where the template argument is named. Nothing about any compiler's format is hard-coded. Both produce byte-identical spellings for every type the suite uses.

Result

  • 283 static_assert declarations removed across 17 files, plus 32 in the exemplar. None remain live; the word survives only in comments.
  • ctest goes from 1095/1095 to 1172/1172 — assertions that were previously invisible are now reported cases.
  • Verified on both type_name implementations, and for the behavioral files against std::expected as well as beman::expected.

Two things worth reviewing closely

expected_std_equivalence.test.cpp is no longer a translation-time gate. Its assertions are generated by BEMAN_PARITY/BEMAN_RUN over a battery of error types; the macros now expand to INFO + CHECK inside a TEST_CASE, so a divergence names the exact (T, E) pair. A consequence: the four pre-existing is_trivially_copyable divergences on clang with libstdc++ — which are four hard static assertion failed errors at HEAD — now surface as named test failures instead of breaking the build. Pre-existing, and now diagnosable, but it is a real change in failure mode for that target.

-freflection is enabled in cmake/gcc-16-toolchain.cmake only. It cannot go in CMakePresets.json, because the preset toolchains use the unversioned g++ and gcc rejects the flag outright below -std=c++26. That leaves the reflection path without CI coverage — tracked in #88.

steve-downey and others added 6 commits August 9, 2026 20:39
Two header-only components that let a compile-time contract be reported by
the test run instead of breaking the build.

constant_eval(probe) is consteval, so calling it is an immediate invocation:
the probe is evaluated during translation and its result is required to be a
constant expression. That answers "is this constant-evaluable?" by
construction, with no static_assert, while handing the result back as an
ordinary prvalue that CHECK can compare and report. The probe is a plain
lambda, so the same body can also be run at runtime -- constant evaluation and
ordinary evaluation take different paths through a union-based type.

type_name<T>() returns the identity of T, compared with std::is_same_v, so a
check is exactly as strict as the static_assert it replaces. The compiler's
spelling is consulted only to explain a failure, turning "false" into
"const int& == int&". It has two implementations of that spelling: P2996
reflection via std::meta::display_string_of when the compiler offers it, and
otherwise recovery from std::source_location::function_name() by calibrating
on the signatures of signature<bool> and signature<char>, which differ only
where the template argument is named. Nothing about any compiler's format is
hard-coded, and self-checks hold whichever implementation is selected to the
same contract.

Note that __cpp_lib_reflection is a library macro: <version> must be included
before testing it, or the reflection path silently never activates.
Selects the P2996 reflection implementation of testing/type_name.hpp, which is
otherwise dormant.

This belongs in the per-version toolchain rather than cmake/gcc-flags.cmake,
which is shared with gcc-12 through gcc-15: -freflection is a gcc-16 feature,
and gcc rejects it outright below -std=c++26 rather than warning.
gcc-flags.cmake already pins -std=gnu++26, so the requirement is met.

It deliberately does not go in CMakePresets.json. The preset toolchains under
infra/cmake set CMAKE_CXX_COMPILER to the unversioned g++, so a preset
carrying this flag would break the build for anyone whose default compiler is
not gcc-16 -- which is nearly everyone today. That leaves the reflection path
without CI coverage; see bemanproject#88.
Establishes the convention the rest of the suite follows.

A static_assert is a poor test: a wrong answer is a translation failure, so
the only thing anyone sees is a compiler diagnostic. The test run reports
nothing, the xUnit output is empty, and because the build stops at the first
failing assertion no other test in the file is exercised at all.

Type-level properties become ordinary CHECKs, and type identities become
type_name comparisons, so a mismatch prints "const int& == int&" rather than
"false". The four constexpr cases build their expected inside a self-contained
probe, run it through constant_eval, and compare the resulting state through a
streamable aggregate -- a wrong value now reports "{ error 7 } == { error 3 }"
while the other 274 assertions in the file still run.

Compile-time enforcement is not lost. Whether something can be constant-
evaluated is still answered by the compiler, because constant_eval's call is an
immediate invocation; only the value comparison moved to the test run.

Also fixes the include block to use canonical <beman/expected/...> spellings.
Applies the convention established in expected.test.cpp to the other 17 files,
removing the last 283 static_assert declarations from the .test.cpp sources.
No live static_assert remains in any test file; the word survives only in
comments explaining why a check is now a CHECK.

These files are almost entirely type-level, so the bulk of the work is traits
and SFINAE concepts becoming CHECK / CHECK_FALSE and is_same_v becoming a
type_name comparison. Polarity and operands are preserved throughout; the
count of passing tests rises from 1095 to 1172 because assertions that used to
be invisible are now reported cases.

Two files needed more than the mechanical treatment:

expected_std_equivalence.test.cpp generates its assertions from BEMAN_PARITY
and BEMAN_RUN macros over a battery of error types. The macros now expand to
INFO + CHECK inside a TEST_CASE, so a trait divergence names the exact (T, E)
pair it was found on instead of failing to compile. This does mean the trait
equivalence target is no longer a translation-time gate. It also means the
four pre-existing is_trivially_copyable divergences on clang with libstdc++ --
which are four hard static assertion failures at HEAD -- now report as named
test failures rather than breaking the build.

expected_ref_constraints.test.cpp brace-scopes each INFO/CHECK pair, because
Catch2 INFO messages accumulate for the enclosing scope and a failing check
would otherwise print the messages of every sibling that ran before it.
Declare the constexpr construction/equality probes in expected.test.cpp
as constexpr objects so they still exercise "usable as a constexpr
variable", matching unexpected.test.cpp. Fix a stale comment in
expected_review_corrections.test.cpp that described a runtime-checked
assignment trait as "rejected at compile time".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nery

Add docs/blog/scrap-your-static_assert.org, an org-mode post covering the two
techniques the test suite uses to report compile-time facts through the runtime
framework: type_name<T>() for type-identity checks and constant_eval for
constexpr probes. Code snippets are pulled from the tree by org-transclusion
against UUID-anchored regions, so the post cannot drift from the code.

Add the anchor comment pairs the post transcludes to type_name.hpp,
constant_eval.hpp, and expected.test.cpp.

Copy the conversion machinery from the compile-time-scheme repo: .emacs.d/
(init.el plus the orgit-file-transclusion.el adapter, base URLs repointed at
this repo) and the blog-md Makefile targets that export docs/blog/*.org to GFM
markdown with transclusions resolved. Pinned orgit-file: links resolve against
an annotated tag; docs/blog/pins.md records the post-to-tag mapping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant