diff --git a/.emacs.d/init.el b/.emacs.d/init.el
new file mode 100644
index 0000000..ebf3d9c
--- /dev/null
+++ b/.emacs.d/init.el
@@ -0,0 +1,247 @@
+;; Save any custom set variable in exordium-custom-file rather than at the end of init.el:
+(setq custom-file (locate-user-emacs-file "custom.el"))
+
+(require 'package)
+(add-to-list 'package-archives '("melpa" . "https://melpa.org/packages/") t)
+(setq package-user-dir
+ (locate-user-emacs-file (concat "elpa-" emacs-version)))
+
+(when (fboundp 'native-comp-available-p)
+ (setq package-native-compile (native-comp-available-p)))
+(package-initialize)
+
+;; Load the packages we need if they are not installed already
+(let ((package-pinned-packages (append
+ '((use-package . "melpa")
+ (diminish . "melpa")
+ (bind-key . "melpa"))))
+ (has-refreshed nil))
+
+ (defun update-package (p has-refreshed)
+ (unless (package-installed-p p)
+ (unless has-refreshed
+ (message "Refreshing package database...")
+ (package-refresh-contents)
+ (setq has-refreshed t)
+ (message "Done."))
+ (package-install p)))
+
+ (dolist (pkg package-pinned-packages)
+ (let ((p (car pkg)))
+ (update-package p has-refreshed))))
+
+;; This is only needed once, near the top of the file
+(eval-when-compile
+ ;; Following line is not needed if use-package.el is in ~/.emacs.d
+ (require 'use-package))
+
+(require 'use-package-ensure)
+(setq use-package-always-ensure t)
+(setq use-package-compute-statistics t)
+;;; remove a package from the builtin list so it can be upgraded
+(defun wg21org-ignore-builtin (pkg)
+ (assq-delete-all pkg package--builtins)
+ (assq-delete-all pkg package--builtin-versions))
+
+
+;;; Org mode
+
+(use-package org
+ :commands (org-mode)
+ :mode (("\\.org\\'" . org-mode))
+ :after (flycheck)
+ :bind
+ (:map org-mode-map
+ ([remap org-toggle-comment] . iedit-mode))
+ :custom
+ (org-confirm-babel-evaluate t)
+ (org-fontify-quote-and-verse-blocks t)
+ (org-fontify-whole-heading-line t)
+ (org-log-into-drawer t)
+ (org-src-fontify-natively t)
+ (org-src-preserve-indentation t)
+ (org-startup-folded nil)
+ (org-startup-indented t)
+ (org-startup-truncated nil)
+ (org-startup-with-inline-images t)
+ (org-support-shift-select :always)
+ (org-use-sub-superscripts "{}")
+
+ ;; Edit settings
+ (org-auto-align-tags nil)
+ (org-tags-column 0)
+ (org-catch-invisible-edits 'show-and-error)
+ (org-special-ctrl-a/e t)
+ (org-insert-heading-respect-content t)
+
+ ;; Org styling, hide markup etc.
+ (org-hide-emphasis-markers nil)
+ (org-pretty-entities t)
+
+ ;; ;;;; code blocks
+ (org-confirm-babel-evaluate nil)
+ (org-src-window-setup 'reorganize-frame)
+ (org-edit-src-persistent-message t)
+ (org-src-fontify-natively t)
+ (org-src-preserve-indentation t)
+ (org-src-tab-acts-natively t)
+ (org-edit-src-content-indentation 0)
+
+ ;; ;;;; export
+ (org-export-with-toc t)
+ (org-export-headline-levels 8)
+ (org-export-dispatch-use-expert-ui nil)
+ (org-html-htmlize-output-type 'css)
+ (org-html-head-include-default-style t)
+ (org-html-head-include-scripts t)
+
+ ;;; visual line mode
+ (visual-line-mode 1)
+
+ :hook ((org-mode . variable-pitch-mode))
+
+ :init
+ (add-hook 'org-src-mode-hool #'(lambda ()
+ (add-to-list 'flycheck-disabled-checkers 'emacs-lisp-checkdoc)))
+
+ :config
+ ;; Enable org-babel for perl, ruby, sh, python, emacs-lisp, C, C++, etc
+ ;; TODO: add extra languages configurable by user
+ (org-babel-do-load-languages
+ 'org-babel-load-languages
+ `((perl . t)
+ (ruby . t)
+ (shell . t)
+ (python . t)
+ (emacs-lisp . t)
+ (C . t)
+ (dot . t)
+ (sql . t))))
+
+(use-package htmlize
+ :ensure t)
+
+
+(use-package graphviz-dot-mode
+ :config
+ (setq graphviz-dot-indent-width 4))
+
+(org-babel-do-load-languages
+ 'org-babel-load-languages
+ (append org-babel-load-languages
+ '((dot . t))))
+
+
+(setq plantuml-jar-path "/usr/share/plantuml/plantuml.jar")
+(setq plantuml-default-exec-mode 'jar)
+
+(setq org-plantuml-jar-path (expand-file-name "/usr/share/plantuml/plantuml.jar"))
+(add-to-list 'org-src-lang-modes '("plantuml" . plantuml))
+(org-babel-do-load-languages
+ 'org-babel-load-languages
+ (append org-babel-load-languages
+ '((plantuml . t))))
+
+(org-babel-do-load-languages
+ 'org-babel-load-languages
+ (append org-babel-load-languages
+ '((ditaa . t))))
+
+(setq org-ditaa-jar-path "/usr/share/ditaa/ditaa.jar")
+
+(setq org-support-shift-select 'always)
+
+
+;; Reveal.js + Org mode
+(use-package org-re-reveal
+ :config
+ (setq org-re-reveal-root "file:////home/sdowney/bld/reveal.js"))
+
+
+(use-package org-transclusion
+ :after org
+ :bind (:map
+ org-mode-map
+ ("C-c C-x T" . org-transclusion-mode))
+ :config
+ (org-transclusion-mode 1)
+ )
+
+
+(use-package ox-gfm
+ :after org)
+
+(use-package with-editor)
+
+(use-package citeproc :ensure t :after org)
+
+;; Export orgit links as GitHub source browser URLs
+(defvar orgit-base-url "https://github.com/steve-downey/expected/blob/main/"
+ "Base URL for exporting orgit links.")
+
+(org-link-set-parameters
+ "orgit"
+ :export (lambda (path desc backend)
+ (let* ((parts (split-string path "::"))
+ (filepath (if (> (length parts) 1) (cadr parts) path))
+ (url (concat orgit-base-url filepath)))
+ (cond
+ ((or (eq 'md backend) (eq 'gfm backend))
+ (format "[`%s`](%s)" (or desc filepath) url))
+ ((eq 'html backend)
+ (format "%s" url (or desc filepath)))
+ (t url)))))
+
+
+
+
+
+
+
+
+
+;; Allow org-transclusion to resolve orgit links to the local file system
+(defun org-transclusion-add-orgit (link plist)
+ "Resolve orgit links into file links in-place."
+ (when (string= "orgit" (org-element-property :type link))
+ (let* ((full-path (org-element-property :path link))
+ (parts (split-string full-path "::"))
+ (repo-dir (car parts))
+ (inner-file (cadr parts))
+ (search-uuid (if (> (length parts) 2) (caddr parts) nil))
+ (actual-file (expand-file-name inner-file repo-dir))
+ (raw-link (concat "file:" actual-file)))
+
+ (when search-uuid
+ (setq raw-link (concat raw-link "::" search-uuid)))
+
+ ;; CRITICAL: Mutate the original link in-place so downstream plugins
+ ;; (like org-transclusion-src-lines) see the correct file path!
+ (org-element-put-property link :type "file")
+ (org-element-put-property link :path actual-file)
+ (org-element-put-property link :raw-link raw-link)
+ (if search-uuid
+ (org-element-put-property link :search-option search-uuid)
+ (org-element-put-property link :search-option nil))))
+ ;; ALWAYS return nil so the NEXT functions (like org-transclusion-add-src-lines) can handle it!
+ nil)
+
+(require 'org-transclusion)
+(add-hook 'org-transclusion-add-functions 'org-transclusion-add-orgit)
+
+;; Pinned transclusion for the epistolary blog posts.
+;;
+;; Two document categories, two policies (docs/epistolary-pinning-plan.md):
+;; - living docs (docs/compiler_architecture.org) transclude via `file:' /
+;; `orgit:' and roll forward with the worktree, which is the point of them;
+;; - blog posts transclude via `orgit-file:' pinned to a `blog/phase-NN' tag,
+;; so a later refactor cannot rewrite the code inside a published entry.
+;;
+;; Both adapters live on `org-transclusion-add-functions' and coexist; the link
+;; type selects the policy. See docs/blog/pins.md for the post-to-tag mapping.
+(add-to-list 'load-path
+ (expand-file-name
+ "lisp" (or (and load-file-name
+ (file-name-directory load-file-name))
+ user-emacs-directory)))
+(require 'orgit-file-transclusion)
diff --git a/.emacs.d/lisp/orgit-file-transclusion.el b/.emacs.d/lisp/orgit-file-transclusion.el
new file mode 100644
index 0000000..86056be
--- /dev/null
+++ b/.emacs.d/lisp/orgit-file-transclusion.el
@@ -0,0 +1,146 @@
+;;; orgit-file-transclusion.el --- transclude UUID-anchored regions at a git rev -*- lexical-binding: t; -*-
+;; SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+;; Provenance: docs/epistolary-pinning-plan.md (section 3), shared across
+;; compile-time-scheme sibling repositories by copy, not by load-path coupling.
+;;
+;; Link form: [[orgit-file:REPO::REV::PATH::UUID]]
+;; Used with: :lines 2- :src LANG :end "UUID end" (unchanged conventions)
+;;
+;; Where the sibling `orgit:' adapter in init.el resolves PATH against the
+;; worktree -- showing the file as of now -- this one resolves it against REV,
+;; so an epistolary post keeps showing the code its prose was written about.
+;; REV is normally an annotated `blog/phase-NN' tag; see docs/blog/pins.md.
+;;
+;; No magit dependency: the blob comes from a `git show' subprocess, so batch
+;; export stays light. The interactive `orgit-file' package (gggion/orgit-file)
+;; uses the same link syntax, so installing it makes these links followable in
+;; a live Emacs, but nothing here requires it.
+
+;;; Commentary:
+;;
+;; Implementation note -- why this extracts a blob to a file instead of
+;; slicing the region itself:
+;;
+;; `org-transclusion-add-src-lines' already implements the exact semantics the
+;; posts depend on, and they are fiddlier than they look. `:end' is EXCLUSIVE
+;; of the line holding the end marker; `:lines 2-' counts forward from the
+;; anchor line found by the search option, not from the top of the file; and
+;; `:src cpp' wraps the result in a fenced block. Reimplementing that slicing
+;; would duplicate three behaviours that must not drift.
+;;
+;; So this adapter mirrors `org-transclusion-add-orgit': it materialises the
+;; pinned blob, rewrites the link in place into a plain `file:' link with the
+;; same `::UUID' search option, and returns nil so the standard src-lines
+;; handler does the work. Post attributes keep meaning exactly what they
+;; meant under worktree resolution -- by construction, not by imitation.
+;;
+;; The blob is written under a path that preserves the original repo-relative
+;; path, so `find-file-noselect' picks the same major mode it would have for
+;; the real file. `org-link-search', which resolves the `::UUID' anchor, is
+;; mode-sensitive, so this is what keeps anchor resolution identical.
+
+;;; Code:
+
+(require 'org-transclusion)
+(require 'subr-x)
+
+(defgroup orgit-file-transclusion nil
+ "Transclude UUID-anchored source regions pinned to a git revision."
+ :group 'org-transclusion)
+
+(defcustom orgit-file-transclusion-cache-dir
+ (expand-file-name "orgit-file-transclusion" temporary-file-directory)
+ "Directory holding blobs extracted from git.
+Contents are disposable: each blob is cached under its resolved commit
+SHA, so a stale entry cannot be served for a different revision."
+ :type 'directory
+ :group 'orgit-file-transclusion)
+
+(defcustom orgit-file-transclusion-base-url
+ "https://github.com/steve-downey/expected/blob/"
+ "Base URL for exporting `orgit-file' links, with a trailing slash.
+The pinned REV is appended, so an exported link is a permalink to the
+revision the post was written against rather than to a moving branch."
+ :type 'string
+ :group 'orgit-file-transclusion)
+
+(defun orgit-file-transclusion--parse (raw)
+ "Split RAW into (REPO REV PATH UUID); signal on any other shape."
+ (let ((parts (split-string raw "::")))
+ (unless (= 4 (length parts))
+ (error "orgit-file link needs REPO::REV::PATH::UUID, got: %s" raw))
+ parts))
+
+(defun orgit-file-transclusion--rev-parse (repo rev)
+ "Resolve REV to a full commit SHA in REPO."
+ (with-temp-buffer
+ (unless (zerop (call-process "git" nil t nil
+ "-C" (expand-file-name repo)
+ "rev-parse" (concat rev "^{commit}")))
+ (error "orgit-file: cannot resolve rev %s in %s (fetch tags?): %s"
+ rev repo (string-trim (buffer-string))))
+ (string-trim (buffer-string))))
+
+(defun orgit-file-transclusion--blob-file (repo rev path)
+ "Materialise PATH at REV in REPO as a local file; return its name.
+The returned path ends in PATH, so the buffer gets the major mode it
+would have had for the real file."
+ (let* ((sha (orgit-file-transclusion--rev-parse repo rev))
+ (cached (expand-file-name
+ path (expand-file-name sha orgit-file-transclusion-cache-dir))))
+ (unless (file-exists-p cached)
+ (make-directory (file-name-directory cached) t)
+ ;; Write via a temp name and rename, so a failed `git show' can never
+ ;; leave a truncated blob behind for a later run to trust.
+ (let ((tmp (concat cached ".partial")))
+ (with-temp-buffer
+ (unless (zerop (call-process "git" nil t nil
+ "-C" (expand-file-name repo)
+ "show" (format "%s:%s" sha path)))
+ (error "orgit-file: git show %s:%s failed in %s: %s"
+ rev path repo (string-trim (buffer-string))))
+ (let ((coding-system-for-write 'no-conversion))
+ (write-region (point-min) (point-max) tmp nil 'quiet)))
+ (rename-file tmp cached t)))
+ cached))
+
+(defun orgit-file-transclusion-add (link _plist)
+ "Resolve an `orgit-file' LINK into a `file' link on the pinned blob.
+Mutates LINK in place and returns nil, so `org-transclusion-add-src-lines'
+applies the post's :lines/:src/:end attributes exactly as it does for a
+worktree-resolved link."
+ (when (string= "orgit-file" (org-element-property :type link))
+ (pcase-let* ((`(,repo ,rev ,path ,uuid)
+ (orgit-file-transclusion--parse
+ (org-element-property :path link)))
+ (blob (orgit-file-transclusion--blob-file repo rev path)))
+ ;; Mutate in place so downstream handlers see the resolved file path.
+ (org-element-put-property link :type "file")
+ (org-element-put-property link :path blob)
+ (org-element-put-property link :raw-link (concat "file:" blob "::" uuid))
+ (org-element-put-property link :search-option uuid)))
+ ;; Always nil: let the next add-function build the payload.
+ nil)
+
+(add-hook 'org-transclusion-add-functions #'orgit-file-transclusion-add)
+
+;; Export `orgit-file' links as forge permalinks at the pinned revision.
+;; Posts currently carry these links only on `#+transclude:' lines, which
+;; org-transclusion consumes before export, so this is for future inline use.
+(org-link-set-parameters
+ "orgit-file"
+ :export
+ (lambda (path desc backend)
+ (let* ((parts (split-string path "::"))
+ (rev (nth 1 parts))
+ (filepath (or (nth 2 parts) path))
+ (url (concat orgit-file-transclusion-base-url rev "/" filepath)))
+ (cond
+ ((memq backend '(md gfm)) (format "[`%s`](%s)" (or desc filepath) url))
+ ((eq backend 'html)
+ (format "%s" url (or desc filepath)))
+ (t url)))))
+
+(provide 'orgit-file-transclusion)
+;;; orgit-file-transclusion.el ends here
diff --git a/.gitignore b/.gitignore
index 0e68d18..8f0e1c2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,10 @@
/uv.lock
.build
/.claude/
+
+# ignore emacs package cache and generated blog artifacts
+/.emacs.d/eln-cache/
+/.emacs.d/elpa-*/
+/.emacs.d/elpa/
+/.emacs.d/custom.el
+*.deps
diff --git a/Makefile b/Makefile
index 90a682b..a75cb86 100755
--- a/Makefile
+++ b/Makefile
@@ -264,6 +264,86 @@ endif
install-uv: ## install uv via `pipx install uv`
$(install_uv_cmd)
+# ------------------------------------------------------------------------------
+# Blog: org-mode -> GFM markdown, with UUID-anchored source transclusion.
+#
+# docs/blog/*.org posts pull code out of the tree with org-transclusion,
+# resolved by the elisp in .emacs.d/. orgit-file: links are pinned to a
+# committed git rev, so a published post keeps showing the code its prose was
+# written about. See docs/blog/pins.md for the post-to-rev mapping.
+# ------------------------------------------------------------------------------
+EMACS := $(shell command -v emacs 2> /dev/null)
+
+ORGFILES := $(wildcard *.org)
+
+%.html : %.org
+ $(EMACS) --init-directory=.emacs.d/ \
+ --batch --load .emacs.d/init.el \
+ -f package-initialize \
+ --eval "(setq enable-local-variables :all)" \
+ --visit $< \
+ --eval "(org-transclusion-mode t)" \
+ --eval "(org-export-to-file 'html \"$@\")"
+ echo $@ : \\ > $@.deps
+ echo " $<" \\ >> $@.deps
+ sed -n "s/^.*\[\[file:\(\S*\)::.*$$/\1/p" < $< | sort -u | xargs printf " %s \\\\\\n" >> $@.deps
+
+-include $(wildcard $(ORGFILES:%.org=%.html.deps))
+
+%-slides.html : %.org
+ $(EMACS) --init-directory=.emacs.d/ \
+ --batch --load .emacs.d/init.el \
+ -f package-initialize \
+ --eval "(setq enable-local-variables :all)" \
+ --visit $< \
+ --eval "(org-transclusion-mode t)" \
+ --eval "(org-export-to-file 're-reveal \"$@\")"
+ echo $@ : \\ > $@.deps
+ echo " $<" \\ >> $@.deps
+ sed -n "s/^.*\[\[file:\(\S*\)::.*$$/\1/p" < $< | sort -u | xargs printf " %s \\\\\\n" >> $@.deps
+
+-include $(wildcard $(ORGFILES:%.org=%-slides.html.deps))
+
+BLOG_ORGFILES := $(wildcard docs/blog/*.org)
+
+docs/blog/%.md : docs/blog/%.org
+ $(EMACS) --init-directory=.emacs.d/ \
+ --batch --load .emacs.d/init.el \
+ -f package-initialize \
+ --eval "(setq enable-local-variables :all)" \
+ --visit $< \
+ --eval "(org-transclusion-mode t)" \
+ --eval "(require 'ox-gfm)" \
+ --eval "(org-export-to-file 'gfm \"$(abspath $@)\")"
+ echo $@ : \\ > $@.deps
+ echo " $<" \\ >> $@.deps
+ sed -n \
+ -e "s/^.*\[\[file:\(\S*\)::.*$$/\1/p" \
+ -e "s/^.*\[\[orgit:[^:]*::\([^:]*\)::.*$$/\1/p" \
+ < $< | sort -u | xargs printf " %s \\\\\\n" >> $@.deps
+
+-include $(wildcard $(BLOG_ORGFILES:.org=.md.deps))
+
+.PHONY: blog-md
+blog-md: $(BLOG_ORGFILES:.org=.md) ## convert docs/blog/*.org to GFM markdown
+
+.PHONY: clean-blog-md
+clean-blog-md:
+ -rm -f $(BLOG_ORGFILES:.org=.md) $(BLOG_ORGFILES:.org=.md.deps)
+clean: clean-blog-md
+
+
+.PHONY: clean-emacs.d
+clean-emacs.d:
+ -rm -rf .emacs.d/eln-cache
+ -rm -rf .emacs.d/elpa*
+
+realclean: clean-emacs.d
+
+.PHONY: clean-org-deps
+clean-org-deps:
+ -rm $(ORGFILES:%.org=%.org.deps)
+
# Help target
.PHONY: help
help: ## Show this help.
diff --git a/cmake/gcc-16-toolchain.cmake b/cmake/gcc-16-toolchain.cmake
index ae1bb01..b7310d3 100644
--- a/cmake/gcc-16-toolchain.cmake
+++ b/cmake/gcc-16-toolchain.cmake
@@ -15,3 +15,17 @@ set(CMAKE_CXX_FLAGS_ASAN
"C++ ASAN Flags"
FORCE
)
+
+# Reflection (P2996). This belongs here rather than in 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.
+#
+# Enabling it selects the reflection implementation of
+# tests/beman/expected/testing/type_name.hpp, which is otherwise dormant.
+set(CMAKE_CXX_FLAGS
+ "${CMAKE_CXX_FLAGS} -freflection"
+ CACHE STRING
+ "CXX_FLAGS"
+ FORCE
+)
diff --git a/docs/blog/pins.md b/docs/blog/pins.md
new file mode 100644
index 0000000..ed9301c
--- /dev/null
+++ b/docs/blog/pins.md
@@ -0,0 +1,23 @@
+# Blog transclusion pins
+
+Each post that transcludes live code is pinned to one annotated tag.
+`#+transclude:` links resolve against that tag's tree, not against the
+worktree, so a later refactor cannot rewrite the code inside an already
+published entry. The transclusion machinery is the copy of `.emacs.d/` and the
+`blog-md` Makefile target carried over from the `compile-time-scheme`
+repository; see `.emacs.d/lisp/orgit-file-transclusion.el`.
+
+## The mapping
+
+| Post | Tag | Basis |
+|---|---|---|
+| `scrap-your-static_assert.org` | `blog/scrap-static-assert` | commit adding the UUID anchors and the post |
+
+## Notes
+
+`orgit-file:` links pin to a tag, so a pinned post's `.md.deps` names only its
+own `.org` and not the transcluded sources — that is correct, not a bug to
+repair. The code comes from an immutable tag, so there is no worktree
+dependency to track; rebuilding the post when the working tree changes would be
+the defect. The `file:`/`orgit:` dependency extraction in the Makefile stays
+useful only for any living document that still resolves against the worktree.
diff --git a/docs/blog/scrap-your-static_assert.md b/docs/blog/scrap-your-static_assert.md
new file mode 100644
index 0000000..7523930
--- /dev/null
+++ b/docs/blog/scrap-your-static_assert.md
@@ -0,0 +1,85 @@
+The obvious way to test a compile-time fact is `static_assert`. It's right there, it needs no framework, and for a fact that has to hold it's the right tool. As a *test*, though, it has one bad property: a wrong answer is a translation failure. The build stops at the first one, you get a compiler diagnostic instead of a test result, and every other test in the file goes unrun. The xUnit report is empty. You learn that something is wrong, once, and nothing about the rest.
+
+There's a second, smaller problem. Even when you write the check as a runtime `CHECK` so that it gets reported, a bare trait doesn't report anything you can use:
+
+```C++
+CHECK(std::is_same_v); // FAILED: CHECK( false )
+```
+
+The expansion is the word `false`. You already knew the two types differed; the framework won't tell you what either of them was.
+
+Converting the `expected` tests off `static_assert` came down to two header-only components that fix these two problems. Neither is clever. (The title owes Lämmel and Peyton Jones; the debt stops at the title.)
+
+
+# Type identity as a value
+
+The fix for the second problem is to compare type *identities* that carry their spelling for diagnostics, instead of comparing a bool. See [`type\_name.hpp`](https://github.com/steve-downey/expected/blob/main/tests/beman/expected/testing/type_name.hpp). The comparison is still `std::is_same_v`, so the verdict is exact and a false pass isn't possible:
+
+```cpp
+template
+constexpr bool beman::expected::testing::operator==(type_name_t, type_name_t) {
+ return std::is_same_v;
+}
+```
+
+The spelling is consulted only after a comparison has already failed and the framework needs to explain it. So a failing check explains itself:
+
+```text
+FAILED: CHECK( type_name() == type_name() )
+with expansion: const int& == int&
+```
+
+And the tests read like the trait they replaced:
+
+```cpp
+TEST_CASE("expected: operator* ref-qualification return types", "[ExpectedTest]") {
+ using expected_t = expt::expected;
+ CHECK(type_name())>() == type_name());
+ CHECK(type_name())>() == type_name());
+ CHECK(type_name())>() == type_name());
+ CHECK(type_name())>() == type_name());
+}
+```
+
+
+# Reporting a compile-time value at runtime
+
+The fix for the first problem is to split the two questions a constexpr test actually asks. "Can this be constant-evaluated at all?" is a property of the code; it stays a hard translation failure, which is correct, because that's a fact that has to hold. "Does it produce the right answer?" is a property of a value, and there's no reason a wrong value should stop the build.
+
+[`constant\_eval.hpp`](https://github.com/steve-downey/expected/blob/main/tests/beman/expected/testing/constant_eval.hpp) is `consteval`, so a call to it is evaluated during translation. If the probe body isn't usable in a constant expression the program is ill-formed, and the first question is answered by the call itself, with no `static_assert` needed. The result then behaves as an ordinary prvalue, free to be handed to `CHECK`. The whole thing is a one-line wrapper:
+
+```cpp
+template
+consteval auto beman::expected::testing::constant_eval(Probe probe) {
+ return probe();
+}
+```
+
+A probe is a plain lambda that reduces what it observes to a literal aggregate:
+
+```cpp
+TEST_CASE("expected: constexpr default construction", "[ExpectedTest]") {
+ constexpr auto probe = [] {
+ constexpr expt::expected e;
+ return int_state{e.has_value(), *e};
+ };
+ CHECK(constant_eval(probe) == int_state{true, 0});
+ CHECK(probe() == int_state{true, 0});
+}
+```
+
+Because the probe is a plain lambda and not a `consteval` one, the same body runs in both evaluation modes. Constant evaluation and ordinary evaluation can take different paths through a union-based type like `expected`, so running both earns its second line:
+
+```C++
+CHECK(constant_eval(probe) == expect); // constant evaluation
+CHECK(probe() == expect); // ordinary evaluation
+```
+
+Give the returned aggregate an `operator<<`. Without one, Catch2 prints `{?} == {?}` and you're back where `static_assert` left you.
+
+
+# What it buys
+
+Two things. The reporting is better: a mismatch names both types, or prints both states, instead of expanding to `false` or stopping at a diagnostic before it can say anything. And a wrong answer is no longer a compile failure that blocks everything behind it. The suite builds, runs, and reports every case; a broken trait shows up as one red line among the green, with the rest of the run intact.
+
+None of this abolishes `static_assert`. The genuinely ill-formed cases stay ill-formed, checked in their own negative-compilation files. What moved to runtime is only the part that was a test wearing an assertion's clothes.
diff --git a/docs/blog/scrap-your-static_assert.org b/docs/blog/scrap-your-static_assert.org
new file mode 100644
index 0000000..2308540
--- /dev/null
+++ b/docs/blog/scrap-your-static_assert.org
@@ -0,0 +1,95 @@
+#+title: Scrap your ~static_assert~
+#+date: <2026-08-10>
+#+author: Steve Downey
+#+OPTIONS: toc:nil num:nil ^:nil
+
+The obvious way to test a compile-time fact is ~static_assert~. It's right
+there, it needs no framework, and for a fact that has to hold it's the right
+tool. As a /test/, though, it has one bad property: a wrong answer is a
+translation failure. The build stops at the first one, you get a compiler
+diagnostic instead of a test result, and every other test in the file goes
+unrun. The xUnit report is empty. You learn that something is wrong, once, and
+nothing about the rest.
+
+There's a second, smaller problem. Even when you write the check as a runtime
+~CHECK~ so that it gets reported, a bare trait doesn't report anything you can
+use:
+
+#+begin_src C++
+CHECK(std::is_same_v); // FAILED: CHECK( false )
+#+end_src
+
+The expansion is the word ~false~. You already knew the two types differed; the
+framework won't tell you what either of them was.
+
+Converting the =expected= tests off ~static_assert~ came down to two
+header-only components that fix these two problems. Neither is clever.
+(The title owes Lämmel and Peyton Jones; the debt stops at the title.)
+
+* Type identity as a value
+
+The fix for the second problem is to compare type /identities/ that carry their
+spelling for diagnostics, instead of comparing a bool. See
+[[orgit:~/src/steve-downey/expected/scrap-static-assert::tests/beman/expected/testing/type_name.hpp][type_name.hpp]]. The
+comparison is still ~std::is_same_v~, so the verdict is exact and a false pass
+isn't possible:
+
+#+transclude: [[orgit-file:~/src/steve-downey/expected/scrap-static-assert::blog/scrap-static-assert::tests/beman/expected/testing/type_name.hpp::d1c7602e-a42f-46ab-bd53-7adef5646545]] :lines 2- :src cpp :end "d1c7602e-a42f-46ab-bd53-7adef5646545 end"
+
+The spelling is consulted only after a comparison has already failed and the
+framework needs to explain it. So a failing check explains itself:
+
+#+begin_src text
+FAILED: CHECK( type_name() == type_name() )
+with expansion: const int& == int&
+#+end_src
+
+And the tests read like the trait they replaced:
+
+#+transclude: [[orgit-file:~/src/steve-downey/expected/scrap-static-assert::blog/scrap-static-assert::tests/beman/expected/expected.test.cpp::52697d22-633f-4e07-b364-5b5db41ca2a7]] :lines 2- :src cpp :end "52697d22-633f-4e07-b364-5b5db41ca2a7 end"
+
+* Reporting a compile-time value at runtime
+
+The fix for the first problem is to split the two questions a constexpr test
+actually asks. "Can this be constant-evaluated at all?" is a property of the
+code; it stays a hard translation failure, which is correct, because that's a
+fact that has to hold. "Does it produce the right answer?" is a property of a
+value, and there's no reason a wrong value should stop the build.
+
+[[orgit:~/src/steve-downey/expected/scrap-static-assert::tests/beman/expected/testing/constant_eval.hpp][constant_eval.hpp]]
+is ~consteval~, so a call to it is evaluated during translation. If the probe
+body isn't usable in a constant expression the program is ill-formed, and the
+first question is answered by the call itself, with no ~static_assert~ needed.
+The result then behaves as an ordinary prvalue, free to be handed to ~CHECK~.
+The whole thing is a one-line wrapper:
+
+#+transclude: [[orgit-file:~/src/steve-downey/expected/scrap-static-assert::blog/scrap-static-assert::tests/beman/expected/testing/constant_eval.hpp::f0b2e22a-6ab4-4112-b826-297a776f01f1]] :lines 2- :src cpp :end "f0b2e22a-6ab4-4112-b826-297a776f01f1 end"
+
+A probe is a plain lambda that reduces what it observes to a literal aggregate:
+
+#+transclude: [[orgit-file:~/src/steve-downey/expected/scrap-static-assert::blog/scrap-static-assert::tests/beman/expected/expected.test.cpp::a525eae8-2bb0-4cae-aeaf-b134cbffef1c]] :lines 2- :src cpp :end "a525eae8-2bb0-4cae-aeaf-b134cbffef1c end"
+
+Because the probe is a plain lambda and not a ~consteval~ one, the same body
+runs in both evaluation modes. Constant evaluation and ordinary evaluation can
+take different paths through a union-based type like ~expected~, so running both
+earns its second line:
+
+#+begin_src C++
+CHECK(constant_eval(probe) == expect); // constant evaluation
+CHECK(probe() == expect); // ordinary evaluation
+#+end_src
+
+Give the returned aggregate an ~operator<<~. Without one, Catch2 prints ~{?} ==
+{?}~ and you're back where ~static_assert~ left you.
+
+* What it buys
+
+Two things. The reporting is better: a mismatch names both types, or prints both
+states, instead of expanding to ~false~ or stopping at a diagnostic before it
+can say anything. And a wrong answer is no longer a compile failure that blocks
+everything behind it. The suite builds, runs, and reports every case; a broken
+trait shows up as one red line among the green, with the rest of the run intact.
+
+None of this abolishes ~static_assert~. The genuinely ill-formed cases stay
+ill-formed, checked in their own negative-compilation files. What moved to
+runtime is only the part that was a test wearing an assertion's clothes.
diff --git a/tests/beman/expected/bad_expected_access.test.cpp b/tests/beman/expected/bad_expected_access.test.cpp
index 3b9c010..c8eef7a 100644
--- a/tests/beman/expected/bad_expected_access.test.cpp
+++ b/tests/beman/expected/bad_expected_access.test.cpp
@@ -5,26 +5,41 @@
#include
+#include
+
#include
#include
+#include
#include
namespace expt = test_ns;
+using beman::expected::testing::type_name;
+
// =============================================================================
-// [expected.bad.void] and [expected.bad] — type-level assertions
+// [expected.bad.void] and [expected.bad] — type-level properties
+//
+// These are checked at runtime rather than with static_assert so that a
+// violated property is reported by the test run, with the responsible type
+// named, instead of stopping the build at the first failure and reporting
+// nothing. Type identity is checked by comparing the compiler's spelling of
+// the two types, so a mismatch prints what was deduced next to what was
+// wanted rather than the bare word `false`.
// =============================================================================
-// Inheritance chain
-static_assert(std::is_base_of_v>);
-static_assert(std::is_base_of_v, expt::bad_expected_access>);
-static_assert(std::is_base_of_v>);
+TEST_CASE("bad_expected_access: inheritance chain", "[BadExpectedAccessTest]") {
+ CHECK(std::is_base_of_v>);
+ CHECK(std::is_base_of_v, expt::bad_expected_access>);
+ CHECK(std::is_base_of_v>);
+}
-// error() ref-qualification return types
-static_assert(std::is_same_v&>().error()), int&>);
-static_assert(std::is_same_v&>().error()), const int&>);
-static_assert(std::is_same_v&&>().error()), int&&>);
-static_assert(std::is_same_v&&>().error()), const int&&>);
+TEST_CASE("bad_expected_access: error() ref-qualification return types", "[BadExpectedAccessTest]") {
+ using bad_access_t = expt::bad_expected_access;
+ CHECK(type_name().error())>() == type_name());
+ CHECK(type_name().error())>() == type_name());
+ CHECK(type_name().error())>() == type_name());
+ CHECK(type_name().error())>() == type_name());
+}
TEST_CASE("bad_expected_access: breathing", "[BadExpectedAccessTest]") {}
diff --git a/tests/beman/expected/expected.test.cpp b/tests/beman/expected/expected.test.cpp
index 8b4a0e0..544ae0e 100644
--- a/tests/beman/expected/expected.test.cpp
+++ b/tests/beman/expected/expected.test.cpp
@@ -1,12 +1,15 @@
// beman/expected/expected.test.cpp -*-C++-*-
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
-#include "test_expected.hpp"
+#include
#include
-#include "testing/types.hpp"
+#include
+#include
+#include
+#include
#include
#include
#include
@@ -15,8 +18,11 @@
namespace expt = test_ns;
+using beman::expected::testing::constant_eval;
+using beman::expected::testing::type_name;
+
// =============================================================================
-// Helper types at namespace scope (needed for static_assert outside functions)
+// Helper types, at namespace scope because more than one test case uses them
// =============================================================================
struct NoDefault {
@@ -42,51 +48,66 @@ struct MightThrow {
};
// =============================================================================
-// [expected.object.general] para 2-3 — type-level static assertions
+// [expected.object.general] para 2-3 — type-level properties
+//
+// These are checked at runtime rather than with static_assert so that a
+// violated property is reported by the test run, with the responsible type
+// named, instead of stopping the build at the first failure and reporting
+// nothing. Type identity is checked by comparing the compiler's spelling of
+// the two types, so a mismatch prints what was deduced next to what was
+// wanted rather than the bare word `false`.
// =============================================================================
// Ill-formed T: reference type — tested via negative compile file expected_t_ref_fail.cpp
// Ill-formed E: reference type — tested via negative compile file expected_e_ref_fail.cpp
// Ill-formed T: array type — tested via negative compile file expected_t_array_fail.cpp
-// Default constructor: requires is_default_constructible_v
-static_assert(!std::is_default_constructible_v>);
+TEST_CASE("expected: special member availability and noexcept", "[ExpectedTest]") {
+ // Default constructor: requires is_default_constructible_v
+ CHECK_FALSE(std::is_default_constructible_v>);
-// Copy constructor: not present when T is not copy-constructible
-static_assert(!std::is_copy_constructible_v>);
+ // Copy constructor: not present when T is not copy-constructible
+ CHECK_FALSE(std::is_copy_constructible_v>);
-// Destructor: trivially destructible when T and E are
-static_assert(std::is_trivially_destructible_v>);
+ // Destructor: trivially destructible when T and E are
+ CHECK(std::is_trivially_destructible_v>);
-// Move constructor: noexcept when T and E are nothrow-move-constructible
-static_assert(std::is_nothrow_move_constructible_v>);
-static_assert(!std::is_nothrow_move_constructible_v>);
+ // Move constructor: noexcept when T and E are nothrow-move-constructible
+ CHECK(std::is_nothrow_move_constructible_v>);
+ CHECK_FALSE(std::is_nothrow_move_constructible_v>);
-// Move assignment: noexcept when all four noexcept conditions hold
-static_assert(std::is_nothrow_move_assignable_v>);
+ // Move assignment: noexcept when all four noexcept conditions hold
+ CHECK(std::is_nothrow_move_assignable_v>);
+}
-// operator* ref-qualification return types
-static_assert(std::is_same_v&>()), int&>);
-static_assert(std::is_same_v&>()), const int&>);
-static_assert(std::is_same_v&&>()), int&&>);
-static_assert(std::is_same_v&&>()), const int&&>);
+// 52697d22-633f-4e07-b364-5b5db41ca2a7
+TEST_CASE("expected: operator* ref-qualification return types", "[ExpectedTest]") {
+ using expected_t = expt::expected;
+ CHECK(type_name())>() == type_name());
+ CHECK(type_name())>() == type_name());
+ CHECK(type_name())>() == type_name());
+ CHECK(type_name())>() == type_name());
+}
+// 52697d22-633f-4e07-b364-5b5db41ca2a7 end
-// error() ref-qualification return types
-static_assert(std::is_same_v&>().error()), int&>);
-static_assert(std::is_same_v&>().error()), const int&>);
-static_assert(std::is_same_v&&>().error()), int&&>);
-static_assert(std::is_same_v&&>().error()), const int&&>);
+TEST_CASE("expected: error() ref-qualification return types", "[ExpectedTest]") {
+ using expected_t = expt::expected;
+ CHECK(type_name().error())>() == type_name());
+ CHECK(type_name().error())>() == type_name());
+ CHECK(type_name().error())>() == type_name());
+ CHECK(type_name().error())>() == type_name());
+}
// =============================================================================
// Type aliases
// =============================================================================
TEST_CASE("expected: type aliases", "[ExpectedTest]") {
- static_assert(std::is_same_v::value_type, int>);
- static_assert(std::is_same_v::error_type, std::string>);
- static_assert(std::is_same_v::unexpected_type, expt::unexpected>);
- static_assert(
- std::is_same_v::rebind, expt::expected>);
+ using expected_t = expt::expected;
+ CHECK(type_name() == type_name());
+ CHECK(type_name() == type_name());
+ CHECK(type_name() == type_name>());
+ CHECK(type_name>() == type_name>());
}
// =============================================================================
@@ -697,30 +718,75 @@ TEST_CASE("expected: cross-type equality error", "[ExpectedTest]") {
// =============================================================================
// Constexpr usage
+//
+// Each of these declares the expected as a constexpr object inside a
+// self-contained probe lambda and reduces its state to a literal aggregate. The
+// constexpr declaration keeps "is this usable as a constexpr variable?" answered
+// by the compiler; `constant_eval` runs the probe during translation — so "is
+// this usable in a constant expression?" is answered too — and hands back the
+// result as an ordinary value,
+// so "did it produce the right state?" is answered by a reported CHECK.
+// Calling the same probe directly runs the identical body at runtime, which
+// is worth doing separately: constant evaluation and ordinary evaluation take
+// different paths through a union-based type.
// =============================================================================
+namespace {
+// The observable state of an expected, reduced to literal types.
+// Streamable so that a mismatch prints both states; without an inserter
+// Catch2 reports `{?} == {?}` and the reporting benefit is lost.
+struct int_state {
+ bool has_value;
+ int observed; // *e when has_value is true, e.error() otherwise
+
+ // HIDDEN FRIENDS
+ friend constexpr bool operator==(const int_state&, const int_state&) = default;
+ friend std::ostream& operator<<(std::ostream& os, const int_state& s) {
+ return os << (s.has_value ? "{ value " : "{ error ") << s.observed << " }";
+ }
+};
+} // namespace
+
+// a525eae8-2bb0-4cae-aeaf-b134cbffef1c
TEST_CASE("expected: constexpr default construction", "[ExpectedTest]") {
- constexpr expt::expected e;
- static_assert(e.has_value());
- static_assert(*e == 0);
+ constexpr auto probe = [] {
+ constexpr expt::expected e;
+ return int_state{e.has_value(), *e};
+ };
+ CHECK(constant_eval(probe) == int_state{true, 0});
+ CHECK(probe() == int_state{true, 0});
}
+// a525eae8-2bb0-4cae-aeaf-b134cbffef1c end
TEST_CASE("expected: constexpr value construction", "[ExpectedTest]") {
- constexpr expt::expected e(42);
- static_assert(e.has_value());
- static_assert(*e == 42);
+ constexpr auto probe = [] {
+ constexpr expt::expected e(42);
+ return int_state{e.has_value(), *e};
+ };
+ CHECK(constant_eval(probe) == int_state{true, 42});
+ CHECK(probe() == int_state{true, 42});
}
TEST_CASE("expected: constexpr error construction", "[ExpectedTest]") {
- constexpr expt::expected e(expt::unexpect, 7);
- static_assert(!e.has_value());
- static_assert(e.error() == 7);
+ constexpr auto probe = [] {
+ constexpr expt::expected e(expt::unexpect, 7);
+ return int_state{e.has_value(), e.error()};
+ };
+ CHECK(constant_eval(probe) == int_state{false, 7});
+ CHECK(probe() == int_state{false, 7});
}
TEST_CASE("expected: constexpr equality", "[ExpectedTest]") {
- constexpr expt::expected a(42);
- constexpr expt::expected b(42);
- static_assert(a == b);
+ // The answer here is a bare bool, so there is no richer value to report;
+ // the win is that a wrong answer is still a reported failure rather than
+ // a build break, and the rest of the file still runs.
+ constexpr auto probe = [] {
+ constexpr expt::expected a(42);
+ constexpr expt::expected b(42);
+ return a == b;
+ };
+ CHECK(constant_eval(probe));
+ CHECK(probe());
}
// =============================================================================
@@ -796,7 +862,7 @@ TEST_CASE("expected: operator-> returns address of value", "[ExpectedTest]") {
TEST_CASE("expected: emplace with nothrow-constructible type", "[ExpectedTest]") {
// int is nothrow constructible — emplace must be available
- static_assert(std::is_nothrow_constructible_v);
+ CHECK(std::is_nothrow_constructible_v);
expt::expected e(expt::unexpect, "err");
int& r = e.emplace(99);
CHECK(r == 99);
@@ -809,10 +875,11 @@ TEST_CASE("expected: emplace with nothrow-constructible type", "[ExpectedTest]")
// =============================================================================
TEST_CASE("expected: value() ref-qualification return types", "[ExpectedTest]") {
- static_assert(std::is_same_v&>().value()), int&>);
- static_assert(std::is_same_v&>().value()), const int&>);
- static_assert(std::is_same_v&&>().value()), int&&>);
- static_assert(std::is_same_v&&>().value()), const int&&>);
+ using expected_t = expt::expected;
+ CHECK(type_name().value())>() == type_name());
+ CHECK(type_name().value())>() == type_name());
+ CHECK(type_name().value())>() == type_name());
+ CHECK(type_name().value())>() == type_name());
}
// =============================================================================
@@ -1120,14 +1187,14 @@ TEST_CASE("cross-eq: expected == expected", "[ExpectedTest][
// ---------------------------------------------------------------------------
TEST_CASE("constraint: from_expected is derived from expected", "[ExpectedTest][constraint]") {
- static_assert(std::is_base_of_v, from_expected>);
+ CHECK(std::is_base_of_v, from_expected>);
from_expected fe(42);
CHECK(fe.has_value());
CHECK(*fe == 42);
}
TEST_CASE("constraint: from_unexpected is derived from unexpected", "[ExpectedTest][constraint]") {
- static_assert(std::is_base_of_v, from_unexpected>);
+ CHECK(std::is_base_of_v, from_unexpected>);
from_unexpected fu(7);
CHECK(fu.error() == 7);
}
diff --git a/tests/beman/expected/expected_constraints.test.cpp b/tests/beman/expected/expected_constraints.test.cpp
index ca53c5f..ccdea59 100644
--- a/tests/beman/expected/expected_constraints.test.cpp
+++ b/tests/beman/expected/expected_constraints.test.cpp
@@ -11,6 +11,18 @@
using namespace beman::expected;
+// =============================================================================
+// How these constraints are checked
+//
+// Each constraint below reduces to a trait, so it is a plain bool. They are
+// checked at runtime rather than at translation time so that a constraint that
+// has drifted is reported by the test run, naming the type and the polarity
+// responsible, instead of stopping the build at the first failure and
+// reporting nothing about the rest. A constraint that must exclude an overload
+// is checked with CHECK_FALSE; the explanatory text each check used to carry is
+// kept as an INFO, so it is printed when that check is the one that fails.
+// =============================================================================
+
// ---------------------------------------------------------------------------
// Converting constructor: bool exemption (constraint 18.3)
//
@@ -33,9 +45,10 @@ TEST_CASE("converting ctor: expected from expected error path
CHECK(dst.error() == 7);
}
-// expected IS constructible from expected (converting ctor selected)
-static_assert(std::is_constructible_v, const expected&>,
- "expected must be constructible from expected via converting ctor");
+TEST_CASE("converting ctor: expected is constructible from expected", "[constraints]") {
+ INFO("expected must be constructible from expected via converting ctor");
+ CHECK(std::is_constructible_v, const expected&>);
+}
// ---------------------------------------------------------------------------
// Value constructor: unexpected guard (constraint 23.4)
@@ -69,10 +82,12 @@ TEST_CASE("value ctor: unexpected blocked as value even when T is construct
// Value ctor is blocked for U = unexpected: is_constructible via value ctor
// path requires U not be an unexpected specialization. Verify by checking that
-// the overall construction resolves correctly (above test covers behavior;
-// the static_assert below checks the trait):
-static_assert(std::is_constructible_v, unexpected>,
- "construction must still work — via unexpected ctor, not value ctor");
+// the overall construction resolves correctly (the test above covers the
+// behavior; the check below covers the trait):
+TEST_CASE("value ctor: expected is still constructible from unexpected", "[constraints]") {
+ INFO("construction must still work — via unexpected ctor, not value ctor");
+ CHECK(std::is_constructible_v, unexpected>);
+}
// ---------------------------------------------------------------------------
// Value assignment: unexpected goes to unexpected overload (constraint 11.2)
@@ -96,17 +111,30 @@ struct ThrowingMove {
ThrowingMove& operator=(ThrowingMove&&) = default;
};
-// Both T and E are throwing-move: move assignment must be deleted
-static_assert(!std::is_move_assignable_v>,
- "move assignment must be deleted when neither T nor E is nothrow move constructible");
-
-// At least one nothrow-move: move assignment must exist
-static_assert(std::is_move_assignable_v>,
- "move assignment must be available when T is nothrow move constructible");
-static_assert(std::is_move_assignable_v>,
- "move assignment must be available when E is nothrow move constructible");
-static_assert(std::is_move_assignable_v>,
- "move assignment must be available when both are nothrow move constructible");
+TEST_CASE("move assignment: availability follows the nothrow-move-constructible condition", "[constraints]") {
+ // Each check is braced so that its INFO is scoped to it alone, and only the
+ // explanation belonging to a failing check is printed.
+
+ // Both T and E are throwing-move: move assignment must be deleted
+ {
+ INFO("move assignment must be deleted when neither T nor E is nothrow move constructible");
+ CHECK_FALSE(std::is_move_assignable_v>);
+ }
+
+ // At least one nothrow-move: move assignment must exist
+ {
+ INFO("move assignment must be available when T is nothrow move constructible");
+ CHECK(std::is_move_assignable_v>);
+ }
+ {
+ INFO("move assignment must be available when E is nothrow move constructible");
+ CHECK(std::is_move_assignable_v>);
+ }
+ {
+ INFO("move assignment must be available when both are nothrow move constructible");
+ CHECK(std::is_move_assignable_v>);
+ }
+}
// ---------------------------------------------------------------------------
// operator==(expected, T2): T2 must not be an expected specialization
@@ -131,8 +159,11 @@ TEST_CASE("operator==: expected compared to int uses value overload", "[constrai
// The T2 value overload must NOT fire when T2 is itself an expected specialization.
// is_constructible check: operator==(expected, expected) must
// resolve via the expected friend, not the T2 value friend.
-// (Behavioral coverage above; static check: ensure T2=expected doesn't pick value overload.)
-static_assert(
- !std::
- is_invocable_r_v, expected>,
- "sanity: plain int equality lambda cannot be called with expected args");
+// (Behavioral coverage above; the check below ensures T2=expected doesn't pick
+// the value overload.)
+using int_equality_t = decltype([](int x, int y) { return x == y; });
+
+TEST_CASE("operator==: a plain int equality callable is not invocable with expected arguments", "[constraints]") {
+ INFO("sanity: plain int equality lambda cannot be called with expected args");
+ CHECK_FALSE((std::is_invocable_r_v, expected>));
+}
diff --git a/tests/beman/expected/expected_hardened.test.cpp b/tests/beman/expected/expected_hardened.test.cpp
index 1560d89..34f30e7 100644
--- a/tests/beman/expected/expected_hardened.test.cpp
+++ b/tests/beman/expected/expected_hardened.test.cpp
@@ -9,6 +9,7 @@
#include
#include
+#include
using namespace beman::expected;
@@ -91,6 +92,12 @@ TEST_CASE("hardened: error() on error-state expected", "[hardened]") {
// ---------------------------------------------------------------------------
// unexpected friend swap: constraint check (beman-only)
+//
+// The constraint is checked at runtime rather than with static_assert so that a
+// violation is reported by the test run, with the responsible type named,
+// instead of stopping the build and reporting nothing. Querying the trait at
+// all is still the point: an unconstrained hidden-friend swap would make
+// is_swappable_v a hard error rather than a well-formed `false`.
// ---------------------------------------------------------------------------
struct NonSwappable {
@@ -100,5 +107,8 @@ struct NonSwappable {
NonSwappable& operator=(const NonSwappable&) = delete;
NonSwappable& operator=(NonSwappable&&) = delete;
};
-static_assert(!std::is_swappable_v);
-static_assert(!std::is_swappable_v>);
+
+TEST_CASE("hardened: unexpected is not swappable when E is not", "[hardened]") {
+ CHECK_FALSE(std::is_swappable_v);
+ CHECK_FALSE(std::is_swappable_v>);
+}
diff --git a/tests/beman/expected/expected_monadic.test.cpp b/tests/beman/expected/expected_monadic.test.cpp
index a81565e..dd05df7 100644
--- a/tests/beman/expected/expected_monadic.test.cpp
+++ b/tests/beman/expected/expected_monadic.test.cpp
@@ -5,6 +5,8 @@
#include
+#include
+
#include "testing/types.hpp"
#include
@@ -13,6 +15,8 @@
using namespace test_ns;
+using beman::expected::testing::type_name;
+
// ---------------------------------------------------------------------------
// and_then
// ---------------------------------------------------------------------------
@@ -144,7 +148,7 @@ TEST_CASE("or_else: value passes through chain", "[expected_monadic]") {
TEST_CASE("transform: has value - transforms", "[expected_monadic]") {
expected e(6);
auto r = e.transform([](int v) { return v * 7; });
- static_assert(std::is_same_v>);
+ CHECK(type_name() == type_name>());
REQUIRE(r.has_value());
CHECK(*r == 42);
}
@@ -165,7 +169,7 @@ TEST_CASE("transform: void return type", "[expected_monadic]") {
expected e(1);
int count = 0;
auto r = e.transform([&](int) { ++count; });
- static_assert(std::is_same_v>);
+ CHECK(type_name() == type_name>());
REQUIRE(r.has_value());
CHECK(count == 1);
}
@@ -174,7 +178,7 @@ TEST_CASE("transform: void return - error state does not call F", "[expected_mon
expected e(unexpect, "no");
int count = 0;
auto r = e.transform([&](int) { ++count; });
- static_assert(std::is_same_v>);
+ CHECK(type_name() == type_name>());
REQUIRE(!r.has_value());
CHECK(count == 0);
CHECK(r.error() == "no");
@@ -183,7 +187,7 @@ TEST_CASE("transform: void return - error state does not call F", "[expected_mon
TEST_CASE("transform: type change", "[expected_monadic]") {
expected e(42);
auto r = e.transform([](int v) -> std::string { return std::to_string(v); });
- static_assert(std::is_same_v>);
+ CHECK(type_name() == type_name>());
REQUIRE(r.has_value());
CHECK(*r == "42");
}
@@ -216,7 +220,7 @@ TEST_CASE("transform: const rvalue overload", "[expected_monadic]") {
TEST_CASE("transform_error: has error - transforms", "[expected_monadic]") {
expected e(unexpect, 3);
auto r = e.transform_error([](int v) -> std::string { return std::to_string(v); });
- static_assert(std::is_same_v>);
+ CHECK(type_name() == type_name>());
REQUIRE(!r.has_value());
CHECK(r.error() == "3");
}
diff --git a/tests/beman/expected/expected_monadic_constraints.test.cpp b/tests/beman/expected/expected_monadic_constraints.test.cpp
index f9aa5bb..359d3a7 100644
--- a/tests/beman/expected/expected_monadic_constraints.test.cpp
+++ b/tests/beman/expected/expected_monadic_constraints.test.cpp
@@ -13,6 +13,19 @@
using namespace beman::expected;
+// =============================================================================
+// How these constraints are checked
+//
+// Every check below is the satisfaction of a detector concept, so it is a
+// plain bool. They are checked at runtime rather than with static_assert so
+// that a constraint that has drifted is reported by the test run, naming the
+// operation and the value category responsible, instead of stopping the build
+// at the first failure and reporting nothing about the rest.
+//
+// The polarity matters as much as the value: an operation that must be
+// constrained *out* is checked with CHECK_FALSE.
+// =============================================================================
+
// A type that is not constructible from lvalue ref (only move-constructible)
struct MoveOnly {
MoveOnly() = default;
@@ -41,65 +54,77 @@ concept has_transform_error = requires(F f) { std::declval().transform_error(
// MoveOnly as E: lvalue overloads (&, const&) are constrained out because
// E is not copy-constructible, so is_constructible_v is false.
-using MoveOnlyErr = expected;
-[[maybe_unused]] auto dummy_and_then = [](int) { return expected(); };
-
-static_assert(!has_and_then);
-static_assert(has_and_then);
-static_assert(!has_and_then);
-
+//
+// The aliases and callables stay at namespace scope, next to the section they
+// belong to: naming them there keeps each detector-concept argument short
+// enough to read, and each case is then a list of the constraints alone.
+using MoveOnlyErr = expected;
+[[maybe_unused]] auto dummy_and_then = [](int) { return expected(); };
[[maybe_unused]] auto dummy_transform = [](int) { return 0; };
-static_assert(!has_transform);
-static_assert(has_transform);
-static_assert(!has_transform);
+TEST_CASE("monadic constraints: and_then / transform on expected", "[monadic][constraints]") {
+ // Lvalue overloads require is_constructible_v, which fails for a
+ // move-only E, so only the rvalue overload survives.
+ CHECK_FALSE(has_and_then);
+ CHECK(has_and_then);
+ CHECK_FALSE(has_and_then);
+
+ CHECK_FALSE(has_transform);
+ CHECK(has_transform);
+ CHECK_FALSE(has_transform);
+}
// ---------------------------------------------------------------------------
// Primary template: or_else / transform_error need T constructible from *this
// ---------------------------------------------------------------------------
-using MoveOnlyVal = expected;
-[[maybe_unused]] auto dummy_or_else = [](int) { return expected(); };
-
-static_assert(!has_or_else);
-static_assert(has_or_else);
-static_assert(!has_or_else);
-
+using MoveOnlyVal = expected;
+[[maybe_unused]] auto dummy_or_else = [](int) { return expected(); };
[[maybe_unused]] auto dummy_transform_error = [](int) { return 0; };
-static_assert(!has_transform_error);
-static_assert(has_transform_error);
-static_assert(!has_transform_error);
+TEST_CASE("monadic constraints: or_else / transform_error on expected", "[monadic][constraints]") {
+ // Lvalue overloads require is_constructible_v, which fails for a
+ // move-only T, so only the rvalue overload survives.
+ CHECK_FALSE(has_or_else);
+ CHECK(has_or_else);
+ CHECK_FALSE(has_or_else);
+
+ CHECK_FALSE(has_transform_error);
+ CHECK(has_transform_error);
+ CHECK_FALSE(has_transform_error