From 63931192746d073f5923c2db3704dcdcab2c7ef9 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 1 Aug 2026 21:23:36 +0000 Subject: [PATCH 01/11] Show how to configure each build Assisted-by: Claude:claude-opus-5 --- CONTRIBUTING.md | 90 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5bd43a48..5f86a089 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,9 +14,16 @@ The headers rely on C++20 only, in every default-mode build. The C++23 validatio ## Building locally -Both `fn` (`include/fn`) and `pfn` (`include/pfn`) target C++20. The unit tests and examples build in C++20 by default. If you have a recent enough compiler, use the CMake option `VALIDATE_CXX23=ON` to additionally build them in C++23 (requires `LIBFN_TESTS=ON`). This also enables `tests/pfn/expected_validation.cpp` and `tests/pfn/optional_validation.cpp`, which run the `pfn` test suites against the standard library's own `std::expected` and `std::optional` — validating the polyfills, and the tests themselves, against the real thing. +Both `fn` (`include/fn`) and `pfn` (`include/pfn`) target C++20. By default, with no options configured, the build compiles the library, the unit tests, and the examples in C++20: -The C++26 mode is separate from the standard level: defining `LIBFN_CXX26` switches the library's internal type ordering to `std::type_order` (see the feature-reliance note above) and moves `fn` into its own ABI namespace, since the two orderings may disagree on `copack`/`choice` alternative order and the layouts must never link as one. The CMake option `LIBFN_CXX26=ON` enables the mode for this build tree, and `VALIDATE_CXX26=ON` (requires `LIBFN_TESTS=ON` and `LIBFN_CXX26=ON`) additionally builds the tests and examples in C++26 with the mode enabled. +```bash +mkdir .build && cd .build +cmake .. +cmake --build . +ctest --output-on-failure +``` + +The sections below describe other build configurations. They vary that `cmake` line and assume the same build directory. `CMAKE_BUILD_TYPE` behaves as usual in `cmake`, so it is omitted from the examples unless an option constrains it. For a quick check of a single example without the full CMake/Catch2 setup: @@ -24,6 +31,85 @@ For a quick check of a single example without the full CMake/Catch2 setup: g++ -std=c++20 -Iinclude examples/polygon/main.cpp -o /tmp/polygon ``` +### C++23 and C++26 + +The unit tests and examples build in C++20 by default. If your compiler is modern enough (GCC 15; Clang 21), use the CMake option `VALIDATE_CXX23=ON` to also build them in C++23 (requires `LIBFN_TESTS=ON`). This enables `tests/pfn/expected_validation.cpp` and `tests/pfn/optional_validation.cpp`. These run the `pfn` test suites against the standard library's `std::expected` and `std::optional` to validate that the polyfills (and the tests themselves) behave exactly like the standard library implementations. + +```bash +cmake -DVALIDATE_CXX23=ON .. +``` + +Setting the CMake option `LIBFN_CXX26=ON` enables C++26 compilation mode, which switches the library's internal type ordering to use C++26's `std::type_order` (see the feature-reliance note above). Because this can change the layout of `copack` and `choice` alternatives, the library uses a distinct ABI namespace to prevent binary compatibility issues. `LIBFN_CXX26=ON` does not select a language version in the compilation options; it needs to be set separately, e.g. with `CMAKE_CXX_STANDARD`, `CXXFLAGS` or in a consumer project. + +Setting `VALIDATE_CXX26=ON` (requires `LIBFN_TESTS=ON` and `LIBFN_CXX26=ON`) additionally builds the tests and examples in C++26 mode, selecting the C++26 language version. Since `std::type_order` is only implemented by GCC 16, this requires GCC 16. If GCC 16 is not your default compiler, select it via `CMAKE_CXX_COMPILER` and `CMAKE_C_COMPILER`, or with both `CC` and `CXX` environment variables: + +```bash +cmake -DLIBFN_CXX26=ON -DVALIDATE_CXX26=ON .. +``` + +### Sanitizers + +Sanitizers require `CMAKE_BUILD_TYPE=Debug`. They are enabled by default for a top-level build when the build type is `Debug` or left unset (including the default build steps above). To build `Debug` without sanitizers: + +```bash +cmake -DLIBFN_SANITIZERS=OFF -DCMAKE_BUILD_TYPE=Debug .. +``` + +The availability of sanitizers varies by platform and compiler: + +* **Linux (GCC & Clang):** Enables Address (ASan), Leak (LSan), and Undefined Behavior (UBSan) sanitizers. +* **macOS (Apple Clang):** Enables ASan and UBSan. LSan is not supported on macOS. +* **Unsupported configurations:** The option is rejected (causes a CMake error) on: + * MSVC + * macOS with Homebrew GCC (due to `libasan` library path issues) + * macOS with Homebrew Clang (due to library mismatches that hang the process) + +### Coverage + +Setting `LIBFN_COVERAGE=ON` adds a `coverage` target that writes a `coverage.xml` report based on previously run tests. This requires `LIBFN_TESTS=ON`, a top-level build, and GCC or Clang: + +```bash +cmake -DLIBFN_COVERAGE=ON .. +cmake --build . +ctest -L 'tests_.*' +cmake --build . --target coverage +``` + +**Requirements:** + +* **gcovr 8.4+:** The coverage build uses the `--merge-lines` option, which was added in `gcovr` 8.4. Since package managers often distribute older versions, using a Python virtual environment (`venv`) is recommended as the easiest way to install a newer version. +* **Coverage tool:** The coverage tool must match your compiler: + * GCC uses `gcov`. + * Clang uses `llvm-cov gcov`. + * Apple Clang uses `llvm-cov` resolved via `xcrun -f`. + These are detected and configured automatically. + +### Documentation + +Setting `LIBFN_DOCS=ON` adds an `export_docs` target that generates the API reference in the `docs/` folder of your build directory. This requires `LIBFN_TESTS=ON` and a top-level build: + +```bash +cmake -DLIBFN_DOCS=ON .. +cmake --build . --target export_docs +``` + +**Requirements:** +The following tools must be installed and available in your `PATH`: + +* **Doxygen** 1.12.0 +* **znai** 1.91 (requires Java/OpenJDK 21 or newer) +* **Graphviz** (provides the `dot` tool used by Doxygen) + +For a reference environment with these exact tool versions, see [`ci/docs/Dockerfile`](ci/docs/Dockerfile). + +**Validation:** +The documentation build does more than just generate HTML. It also verifies that: + +* Every documented entity is present on the generated site. +* The API signatures in the documentation exactly match the C++ headers. + +The target will fail if the documentation is out-of-date or has broken references. + ## Unit tests Although we aim for 100% unit-test coverage, executing every line and branch tells only part of the story in this project. Many important guarantees are compile-time properties, such as overload resolution, conversions, constraints and `noexcept` specifications. Tests must therefore exercise an interface with combinations of dimensions, not merely execute every line. From c773b706ef160c63b102c25de4210520ebd480db Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 1 Aug 2026 21:24:27 +0000 Subject: [PATCH 02/11] Name the C++26 entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also drops the claim that libfn suits asynchronous composition. Nothing in the repository backs it: no example, no reference page, and the paragraph's one substantiated sentence — that an operation is a value, applied when a monad is piped into it — is already made where callables as values are discussed. Assisted-by: Claude:claude-opus-5 --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d95a8f66..c066995f 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,6 @@ The library features demonstrated by the code example above: The example also demonstrates how well libfn works with general programming idioms. `make` is a *smart constructor* — the only way to build a `Rational` — enforcing the type's invariants and returning `expected`: callers never need to re-check what the type guarantees. Treating *callables as values* lets operations such as `and_then` accept `make` whole, carrying its overload set. -These properties also make libfn a natural fit for asynchronous composition, such as coroutines or senders/receivers. Operations and monadic types alike are plain values: `and_then(f)` is a *description* of a step, executed only when a monad is piped into it (an input to the sequence, or the result of the preceding operation). A framework can hold the steps of a computation and apply them as results arrive, with a strongly typed error channel and no hidden control flow — exactly what such programming models need. - Beyond the example: `choice` (a monad over `copack`); the same operations over `optional` as over `expected`; simultaneous disjunction (using `operator|` to fallback-combine monadic computations) and its `fn::disjoin` fold; `fn::conjoin` for simultaneous product folds; tuple protocol in `pack` (`get(p)` or structured bindings); `pack` and `copack` are both structural types (a `constexpr` value which may be used as a template parameter); support for immovable values and callables; an extensible pipeline, where a verb defined outside the library pipes exactly like the built-in ones; and more — see [examples/](examples/) and the [API reference][docs]. ## How @@ -146,13 +144,17 @@ This library requires a total ordering of types, which the standard provides fro ## Using the library -The library is header-only. The CMake package exports two targets: +The library is header-only. The CMake package exports `libfn::fn` and `libfn::pfn`: ```cmake find_package(libfn CONFIG REQUIRED) target_link_libraries(main PRIVATE libfn::fn) # or libfn::pfn for the polyfills alone ``` +A third target, `libfn::fn_cxx26`, is the same headers entered with the [`LIBFN_CXX26` mode](#implementation-note) selected; it carries both the mode and its C++26 language requirement, so a target opts in with one link line. Link exactly one of `libfn::fn` or `libfn::fn_cxx26` per target. `pfn` is mode-independent and has no such variant. + +With `libfn::fn_cxx26`, a compiler that does not implement `std::type_order` stops at the first libfn header, with an `#error` naming the feature. Mixing the two entry points in one binary stops at the linker, on an undefined reference whose type names differ from the definition's by the `_cxx26` ABI namespace. Both are loud by design: the namespaces are separate so that two layouts cannot merge unnoticed. + Packaging is provided — and exercised by CI — for [conan](conanfile.py), [vcpkg](ports/libfn) (an in-repo port), [Nix](flake.nix) and [Bazel](MODULE.bazel); plain CMake `FetchContent` or `add_subdirectory` works as well. Until the first tagged release, consume a pinned git revision — and read [Backwards compatibility](#backwards-compatibility). Every packaging route above except Bazel also delivers the compile options the headers require. Under Bazel — and a plain copy of `include/` — these options don't arrive automatically; provide them yourself: C++20 or newer (`--cxxopt=-std=c++20` in Bazel), `-Wno-missing-braces` on clang (`fn::pack` initialization elides braces by design), and with MSVC `/permissive-` plus `_HAS_CXX23`. The authoritative set is the `INTERFACE` options in [cmake/CompilationOptions.cmake](cmake/CompilationOptions.cmake). From e85bcf65fe7862594f01766a0d397e2a9802571e Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 1 Aug 2026 21:46:54 +0000 Subject: [PATCH 03/11] Render GitHub's alerts as znai attention blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site showed each of TYPE_ALGEBRA's fourteen alerts as a plain blockquote opening with a literal `[!NOTE]` line. The quoting comes off before the existing rewrites, so that a heading or a link inside an alert is treated like any other — the heading pattern is line-anchored and never matched a quoted one — and the block is fenced afterwards, longer than any fence it contains. znai builds an attention block for a fixed set of types and turns anything else into a code snippet that prints the markdown verbatim, which is how `attention-tip` passed a green build. An unmapped kind now fails the staging instead. `[!TIP]` maps to a note block, there being no `tip`; #394 covers giving the mathematical asides their own styling. Assisted-by: Claude:claude-opus-5 --- scripts/stage_docs_source.py | 57 ++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/scripts/stage_docs_source.py b/scripts/stage_docs_source.py index 89b7ce72..ac532b78 100644 --- a/scripts/stage_docs_source.py +++ b/scripts/stage_docs_source.py @@ -48,6 +48,20 @@ HEADING = re.compile(r"^(#{3,})(?=\s)") CODE_SPAN = re.compile(r"`+[^`]*`+") +# GitHub spells an admonition as a blockquote opening with an alert kind; znai spells it as a +# fenced attention block. znai builds a block only for the types below — `attention-tip` is not +# among them, and an unknown type falls through to a code snippet that prints the markdown +# verbatim, so an unmapped kind fails the staging instead of reaching the site looking like that. +ALERT = re.compile(r"^>\s*\[!([A-Z]+)\]\s*$") +ATTENTION = {"NOTE": "note", "TIP": "note", "WARNING": "warning"} +# Markers stand in for the block between the two rewrites, so that the alert's own body is +# rewritten as ordinary prose and only then fenced. +ATTENTION_OPEN = "" +ATTENTION_CLOSE = "" +MARKED = re.compile(r"^$(.*?)^$", + re.MULTILINE | re.DOTALL) +FENCE = re.compile(r"^\s*(`{3,})", re.MULTILINE) + def fail(message: str) -> None: sys.stderr.write(f"Error: {message}\n") @@ -135,6 +149,45 @@ def outside_code(line: str, rewrite) -> str: return "".join(parts) + rewrite(line[last:]) +def unquote_alerts(body: str) -> str: + """Mark GitHub's alert blockquotes and strip the quoting from their bodies. + + The body is freed of its `>` here, ahead of the rewrites below, so that a heading or a link + inside an alert is treated exactly like one outside it; the marker becomes a fence only once + those rewrites have run, since a fenced body is left alone. + """ + lines: list[str] = [] + alert = ALERT.match("") + for line in body.splitlines(): + if alert is None and (alert := ALERT.match(line)): + kind = alert.group(1) + if kind not in ATTENTION: + fail(f"alert [!{kind}] has no znai attention block; " + f"known kinds are {', '.join(sorted(ATTENTION))}") + lines.append(ATTENTION_OPEN.format(ATTENTION[kind])) + continue + if alert is not None: + if line.startswith(">"): + lines.append(line[2:] if line.startswith("> ") else line[1:]) + continue + lines.append(ATTENTION_CLOSE) + alert = None + lines.append(line) + if alert is not None: + lines.append(ATTENTION_CLOSE) + return "\n".join(lines) + + +def fence_alerts(body: str) -> str: + """Turn each marked alert into an attention block, fenced longer than anything inside it.""" + def block(match: re.Match[str]) -> str: + inner = match.group(2).strip("\n") + ticks = "`" * max(3, *(len(m.group(1)) + 1 for m in FENCE.finditer(inner)), 3) + return f"{ticks}attention-{match.group(1)}\n{inner}\n{ticks}" + + return MARKED.sub(block, body) + + def render(body: str, page: dict[str, str] | None, number: dict[str, tuple[str, str]], chapter: str | None, repo: pathlib.Path) -> str: """Rewrite a body for the site: heading depth, links, cross-references.""" @@ -149,7 +202,7 @@ def prose(text: str) -> str: else fail(f"reference to Section {m.group(1)}, which {chapter} does not have"), text) lines = [] - for _, line, fenced in outside_fences(body): + for _, line, fenced in outside_fences(unquote_alerts(body)): if not fenced: if BADGE.match(line.strip()): continue @@ -159,7 +212,7 @@ def prose(text: str) -> str: line = HEADING.sub(lambda m: m.group(1)[1:], line) line = outside_code(line, prose) lines.append(line) - return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)) + return fence_alerts(re.sub(r"\n{3,}", "\n\n", "\n".join(lines))) def write(path: pathlib.Path, title: str, body: str) -> None: From eed658d5739f8d859baabae2870cda5be8ccf4a1 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 1 Aug 2026 21:55:39 +0000 Subject: [PATCH 04/11] Leave alert syntax alone where a fence quotes it The alert scan walked the lines itself rather than through `outside_fences`, which this file has so that no rule fires on fenced content. A fence quoting `> [!NOTE]` as an example of the syntax therefore had its quoting stripped and markers planted inside the code. No document quotes it today, so nothing reached the site that way; the invariant is the point. Assisted-by: Claude:claude-opus-5 --- scripts/stage_docs_source.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/stage_docs_source.py b/scripts/stage_docs_source.py index ac532b78..ff147105 100644 --- a/scripts/stage_docs_source.py +++ b/scripts/stage_docs_source.py @@ -158,7 +158,14 @@ def unquote_alerts(body: str) -> str: """ lines: list[str] = [] alert = ALERT.match("") - for line in body.splitlines(): + for _, line, fenced in outside_fences(body): + if fenced: + # A fence may quote alert syntax as an example of it; that is code, not an alert. + if alert is not None: + lines.append(ATTENTION_CLOSE) + alert = None + lines.append(line) + continue if alert is None and (alert := ALERT.match(line)): kind = alert.group(1) if kind not in ATTENTION: From 7dbec701fd16588dc60c92bd50903252be3e9853 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 1 Aug 2026 22:10:25 +0000 Subject: [PATCH 05/11] Refuse an alert body that continues unquoted A blockquote also swallows an unquoted line that merely continues its paragraph, and only a block of its own ends it. Ending the alert at every unquoted line would split such a body across the attention block, silently. No document does this today, so the conversion refuses the ambiguity rather than guessing at it. Assisted-by: Claude:claude-opus-5 --- scripts/stage_docs_source.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/stage_docs_source.py b/scripts/stage_docs_source.py index ff147105..3eec1720 100644 --- a/scripts/stage_docs_source.py +++ b/scripts/stage_docs_source.py @@ -60,6 +60,9 @@ ATTENTION_CLOSE = "" MARKED = re.compile(r"^$(.*?)^$", re.MULTILINE | re.DOTALL) +# Unquoted openings that end a blockquote rather than continue its paragraph: a heading, a list +# item, a fence and a thematic break. +ENDS_QUOTE = re.compile(r"^(#{1,6}\s|[-*+]\s|\d+[.)]\s|```|~~~|(-{3,}|\*{3,}|_{3,})\s*$)") FENCE = re.compile(r"^\s*(`{3,})", re.MULTILINE) @@ -177,6 +180,12 @@ def unquote_alerts(body: str) -> str: if line.startswith(">"): lines.append(line[2:] if line.startswith("> ") else line[1:]) continue + # A blockquote also swallows an unquoted line that merely continues its paragraph, and + # only a block of its own ends it. Guessing which this is would silently split the + # alert, so anything but a new block is refused and the author quotes it. + if line.strip() and not ENDS_QUOTE.match(line): + fail(f"alert body continues into an unquoted line: {line.strip()[:60]!r}; " + "prefix it with `>` or separate it with a blank line") lines.append(ATTENTION_CLOSE) alert = None lines.append(line) From 4dcf68a2c3c89e36bfa22e36609cc620720d0ee5 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 1 Aug 2026 22:21:37 +0000 Subject: [PATCH 06/11] Close a fence on its own marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `outside_fences` recognised only backtick fences, so a `~~~` block was exposed to every rewrite the staging performs — links, headings and badges as much as alerts. It now tracks the marker that opened the fence and closes on that character alone, at a run no shorter, so a fence may quote a shorter one of either kind. No document uses tildes today; the staged output is unchanged. Assisted-by: Claude:claude-opus-5 --- scripts/stage_docs_source.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/scripts/stage_docs_source.py b/scripts/stage_docs_source.py index 3eec1720..0422a666 100644 --- a/scripts/stage_docs_source.py +++ b/scripts/stage_docs_source.py @@ -63,7 +63,8 @@ # Unquoted openings that end a blockquote rather than continue its paragraph: a heading, a list # item, a fence and a thematic break. ENDS_QUOTE = re.compile(r"^(#{1,6}\s|[-*+]\s|\d+[.)]\s|```|~~~|(-{3,}|\*{3,}|_{3,})\s*$)") -FENCE = re.compile(r"^\s*(`{3,})", re.MULTILINE) +FENCE = re.compile(r"^\s*(`{3,}|~{3,})") +BACKTICKS = re.compile(r"^\s*(`{3,})", re.MULTILINE) def fail(message: str) -> None: @@ -78,13 +79,17 @@ def slug(text: str) -> str: def outside_fences(text: str): """Yield (index, line, inside_fence) so no rule fires on fenced content.""" - fence = False + opening = None for index, line in enumerate(text.splitlines()): - if line.lstrip().startswith("```"): - fence = not fence + run = FENCE.match(line) + # A fence closes on its own character and on a run no shorter than the one that opened it, + # so a fence may quote a shorter one of either kind without ending itself. + if run and (opening is None + or (run.group(1)[0] == opening[0] and len(run.group(1)) >= len(opening))): + opening = None if opening else run.group(1) yield index, line, True continue - yield index, line, fence + yield index, line, opening is not None def split(text: str, where: str) -> tuple[str, str, list[tuple[str, str]]]: @@ -198,7 +203,7 @@ def fence_alerts(body: str) -> str: """Turn each marked alert into an attention block, fenced longer than anything inside it.""" def block(match: re.Match[str]) -> str: inner = match.group(2).strip("\n") - ticks = "`" * max(3, *(len(m.group(1)) + 1 for m in FENCE.finditer(inner)), 3) + ticks = "`" * max(3, *(len(m.group(1)) + 1 for m in BACKTICKS.finditer(inner)), 3) return f"{ticks}attention-{match.group(1)}\n{inner}\n{ticks}" return MARKED.sub(block, body) From 079f96b26676ec22f231f5583d6a5ee5786673b5 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 1 Aug 2026 22:39:22 +0000 Subject: [PATCH 07/11] Allow an alert the indent markdown allows it A block may carry three spaces of indent before it, so an alert marker away from column zero was read as ordinary prose and a slightly indented list or fence after one was read as a paragraph that had lost its quoting, failing the staging. Both now admit those three spaces, and a fourth still makes the line code rather than either. Assisted-by: Claude:claude-opus-5 --- scripts/stage_docs_source.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/stage_docs_source.py b/scripts/stage_docs_source.py index 0422a666..c7a2d8b2 100644 --- a/scripts/stage_docs_source.py +++ b/scripts/stage_docs_source.py @@ -52,7 +52,8 @@ # fenced attention block. znai builds a block only for the types below — `attention-tip` is not # among them, and an unknown type falls through to a code snippet that prints the markdown # verbatim, so an unmapped kind fails the staging instead of reaching the site looking like that. -ALERT = re.compile(r"^>\s*\[!([A-Z]+)\]\s*$") +ALERT = re.compile(r"^ {0,3}>\s*\[!([A-Z]+)\]\s*$") +QUOTED = re.compile(r"^ {0,3}> ?") ATTENTION = {"NOTE": "note", "TIP": "note", "WARNING": "warning"} # Markers stand in for the block between the two rewrites, so that the alert's own body is # rewritten as ordinary prose and only then fenced. @@ -61,8 +62,9 @@ MARKED = re.compile(r"^$(.*?)^$", re.MULTILINE | re.DOTALL) # Unquoted openings that end a blockquote rather than continue its paragraph: a heading, a list -# item, a fence and a thematic break. -ENDS_QUOTE = re.compile(r"^(#{1,6}\s|[-*+]\s|\d+[.)]\s|```|~~~|(-{3,}|\*{3,}|_{3,})\s*$)") +# item, a fence and a thematic break. Markdown allows a block three spaces of indent, here as +# above, and a fourth would make the line code rather than any of these. +ENDS_QUOTE = re.compile(r"^ {0,3}(#{1,6}\s|[-*+]\s|\d+[.)]\s|```|~~~|(-{3,}|\*{3,}|_{3,})\s*$)") FENCE = re.compile(r"^\s*(`{3,}|~{3,})") BACKTICKS = re.compile(r"^\s*(`{3,})", re.MULTILINE) @@ -182,8 +184,8 @@ def unquote_alerts(body: str) -> str: lines.append(ATTENTION_OPEN.format(ATTENTION[kind])) continue if alert is not None: - if line.startswith(">"): - lines.append(line[2:] if line.startswith("> ") else line[1:]) + if quoted := QUOTED.match(line): + lines.append(line[quoted.end():]) continue # A blockquote also swallows an unquoted line that merely continues its paragraph, and # only a block of its own ends it. Guessing which this is would silently split the From bea36a82cc88124f87f046810b9f06e0830c88ce Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sun, 2 Aug 2026 11:16:39 +0100 Subject: [PATCH 08/11] Close a fence on a bare marker only A closing fence carries no info string, so a line opening a nested fence of equal length is content, not the closer. Also state the recognizer's conservative scope in the module docstring. Assisted-by: Claude:claude-fable-5 --- scripts/stage_docs_source.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/stage_docs_source.py b/scripts/stage_docs_source.py index c7a2d8b2..903b0547 100644 --- a/scripts/stage_docs_source.py +++ b/scripts/stage_docs_source.py @@ -8,6 +8,9 @@ a page carries the name alone, and the prose's `Section N` cross-references become links to the pages they name. Repo-relative links, which znai would otherwise resolve as page references and reject, become links to the sibling section or to the file on GitHub. + +The rewrites recognize the markdown these documents use, conservatively: an ambiguous or +unrecognized construct fails the staging rather than guessing its way onto the site. """ from __future__ import annotations @@ -84,10 +87,12 @@ def outside_fences(text: str): opening = None for index, line in enumerate(text.splitlines()): run = FENCE.match(line) - # A fence closes on its own character and on a run no shorter than the one that opened it, - # so a fence may quote a shorter one of either kind without ending itself. + # A fence closes on its own character, a run no shorter than the one that opened it, and + # nothing after the run — a closer carries no info string — so a fence may quote a shorter + # fence of either kind, or an opening fence of any length, without ending itself. if run and (opening is None - or (run.group(1)[0] == opening[0] and len(run.group(1)) >= len(opening))): + or (run.group(1)[0] == opening[0] and len(run.group(1)) >= len(opening) + and not line[run.end():].strip())): opening = None if opening else run.group(1) yield index, line, True continue From 95eaf7aeaeebcfbccdc34fa3244a2a663cbcff50 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sun, 2 Aug 2026 12:16:08 +0100 Subject: [PATCH 09/11] Make TYPE_ALGEBRA.md the design authority for agents CLAUDE.md gains the design gate, the documentation duty and the editing protocol; TYPE_ALGEBRA.md gains the document-guidelines contract, with a structural index as its checksum. The Assisted-by trailer folds into the existing convention, vendor-neutral, citing the kernel guidelines it follows. Assisted-by: Claude:claude-fable-5 --- CLAUDE.md | 14 ++++++++++++-- TYPE_ALGEBRA.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6ff7df61..931a6cdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,9 @@ composition and types) layered over `pfn` (polyfills of C++23/26 vocabulary type ## Collaboration - Pushing back **and** asking questions is welcome — a challenged design beats a silently implemented flawed one. +- Both user and yourself are fallible — take review feedback seriously, encourage using other LLMs for help and review. +- Test every design change against TYPE_ALGEBRA.md (see Docs and Code): it must be expressible there without contradicting the algebra's foundations — additions are welcome, contradictions are to be challenged. +- Always consider whether change or addition might cause the user consuming this library to write unsafe code unwittingly, and challenge such changes. ## CI @@ -17,7 +20,7 @@ composition and types) layered over `pfn` (polyfills of C++23/26 vocabulary type ## Commits & GitHub text -- Trailer `Assisted-by: Claude:` (Linux-kernel convention), e.g. `claude-opus-4-8`. This replaces the harness's trailer boilerplate entirely — no `Co-Authored-By:`, no `Claude-Session:` URL. Same rule for GitHub issues, PRs and comments: `Assisted-by:` is welcome; no other footers or attribution boilerplate. +- Trailer `Assisted-by: :` ([Linux-kernel convention](https://docs.kernel.org/process/coding-assistants.html)), e.g. `Claude:claude-opus-4-8`. This replaces the harness's trailer boilerplate entirely — no `Co-Authored-By:`, no `Claude-Session:` URL. Same rule for GitHub issues, PRs and comments: `Assisted-by:` is welcome; no other footers or attribution boilerplate. - Offer commits; never commit without the user's confirmation — which may be relayed to a commit subagent by the parent that received it. Terse messages: imperative topic; body only when the change needs a *why* (the routing rule in Code names that case). - If a pre-commit hook rewrites staged files, the commit aborts — re-stage the same files and retry once (CONTRIBUTING `## Pre-commit`). - A feature or fix commit should include a test for the behaviour it changes; exceptions are allowed. The PR must contain such a test somewhere unless the behaviour is inherently untestable (for example, because of language or compiler limitations); explain the omission. @@ -39,6 +42,7 @@ composition and types) layered over `pfn` (polyfills of C++23/26 vocabulary type - Don't create `.md`/summary/planning files unless asked (memory files are exempt — see Memory). - A new file's copyright year = the year it enters the codebase (the current year; if unsure, infer from the latest commit). - In `include/` headers, anchor the standard library as `::std::`, never bare `std::` — a user's `fn::std` would otherwise win lookup inside namespace `fn`. Not needed in tests. +- Every major change or addition must be documented in TYPE_ALGEBRA.md (see Docs). Major changes are those that impact the design of code consuming this library. ## Layering @@ -78,6 +82,12 @@ composition and types) layered over `pfn` (polyfills of C++23/26 vocabulary type - Map: README.md = user-facing overview (purpose, usage, project shape, support surface; no agent directives, no internal mechanics; CI surfaced as evidence only, never mechanics); CONTRIBUTING.md = contributor facts (coding + tests standards, build environment, workflows, all CI details, mechanics of every aspect; no agent directives, no library usage); TYPE_ALGEBRA.md = the design document — the library's type algebra worked from first principles; CHANGELOG.md = design history (dated entries, newest first); docs/ = API reference (Doxygen + znai → Pages; also usage); CLAUDE.md = agent practice + guardrails pointing into the above. - Fenced C++ examples in README.md and TYPE_ALGEBRA.md are generated from sources in `examples/` by `scripts/sync_md_examples.py` (pre-commit keeps them in sync) — edit the example source, never the fence; prose edits stay outside fences. +- When updating TYPE_ALGEBRA.md, follow the guidelines in the `document-guidelines` HTML comment at the bottom of the document. - Living documents (README, CONTRIBUTING, docs/) are timeless present tense — no "now", "no longer", "previously". A change that obsoletes documented design gets a dated CHANGELOG.md entry in the same change, saying what it obsoleted and why. -- Recency-bias and wordiness defence (all human-readable text — docs, code comments, and the like; CHANGELOG.md and commit messages are exempt — change-perspective is their correct form): before keeping new text, test — (1) day-one: would this sentence exist had the feature or fix always been here? (2) effort: is detail sized by reader need, or by how hard the work was? — a hard-won bugfix earns no extra words; its history lives in `git log`/`git blame`/CHANGELOG.md; (3) placement: is it where a newcomer looks, or where your recent work pulls it? (4) economy: could fewer words say it as well? After editing, delegate a whole-file top-to-bottom reread to a subagent briefed as a first-time reader, blind to what changed — never review only your diff. Triage its findings: fix what your edit touches, surface the rest rather than rewriting unasked. +- Recency-bias and wordiness defence — applies to all prose (docs, code comments, and the like); CHANGELOG.md and commit messages are exempt, change-perspective being their correct form. Before keeping new text, apply all four tests: + - *Day-one*: would this sentence exist had the feature or fix always been here? + - *Effort*: is detail sized by reader need, or by how hard the work was? A hard-won bugfix earns no extra words; its history lives in `git log`/`git blame`/CHANGELOG.md. + - *Placement*: is it where a newcomer looks, or where your recent work pulls it? + - *Economy*: could fewer words say it as well? +- After editing prose, delegate a whole-file top-to-bottom reread to a subagent briefed as a first-time reader, blind to what changed — never review only your diff. Triage its findings: fix what your edit touches, surface the rest rather than rewriting unasked. - On memory or practice changes, check the root `.md` files for drift from reality and **offer** fixes (CLAUDE.md = practice, README/CONTRIBUTING = facts). diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 43607af2..4778f73b 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -1077,3 +1077,50 @@ For formal validation of the algebraic structures modeled in `libfn`, refer to: 1. Orchard and Petricek, [“Embedding effect systems in Haskell”](https://www.doc.ic.ac.uk/~dorchard/publ/haskell14-effects.pdf) (for effect sets, union, and subeffecting). 2. Orchard, Wadler, and Eades, [“Unifying graded and parameterised monads”](https://arxiv.org/pdf/2001.10274) specifically Definition 21 (for the graded-monad interpretation). 3. McDermott and Uustalu, [“Flexibly Graded Monads and Graded Algebras”](https://dylanm.org/flexibly-graded-monads.pdf) _Note: `libfn` does not claim to fully implement their flexibly graded construction, but the work contextualizes graded structures._ + + From 3f2356188d711f32b22e4e3f194a3585cdf6af66 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sun, 2 Aug 2026 12:40:01 +0100 Subject: [PATCH 10/11] Spell out what TYPE_ALGEBRA left the reader to infer Section 4's pointer to Section 6 lands on prose stating the equivalence, not on an inference from the example. The placeholder types join the quoted definitions block; only the sync markers move, the example code is unchanged. Assisted-by: Claude:claude-fable-5 --- TYPE_ALGEBRA.md | 12 ++++++++++-- examples/type_algebra/main.cpp | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 4778f73b..9280c547 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -33,10 +33,16 @@ In prose, we omit prefixes (writing `apply`, `transform`, `and_then`, `expected` Although different types can behave identically during application, they remain strictly distinct in memory. For example, `pack`, `std::tuple`, and `std::pair` all unpack into the same call shape `f(a, b)` during `apply`, but they are separate C++ types with distinct layouts. Application does not silently convert or unify types on the storage side. To illustrate these concepts, the examples in this document use a reusable set of value and error types: - ```cpp +struct Error {}; +struct OtherError {}; + +struct A {}; +struct B {}; +struct C {}; +struct D {}; + struct UserId {}; struct User {}; struct FilePath {}; @@ -430,6 +436,8 @@ The runtime semantics are exact: Unlike disjunction (Section 7), `operator&` applies to payload types. When an operand is `copack`, it performs a Cartesian distribution, yielding a `copack` of `pack`s. A `pack` on the opposite side widens each of those `pack`s. +This distribution underpins the rule in Section 4 that a `pack` cannot contain a `copack`. The disallowed nested shape—a product over a sum, (A × B) × (C + D)—is algebraically equivalent to (A × B × C) + (A × B × D), a flat `copack` of `pack`s. This is the sum-of-products normal form that the algebra preserves. No expressive power is lost by refusing the nested shape: conjoining a `pack` with a `copack` yields the equivalent flat representation directly, as the second case below shows. + ```cpp auto test_cartesian_distribution(fn::copack_for ab, fn::copack_for cd) -> void diff --git a/examples/type_algebra/main.cpp b/examples/type_algebra/main.cpp index c0aca402..dca67560 100644 --- a/examples/type_algebra/main.cpp +++ b/examples/type_algebra/main.cpp @@ -19,6 +19,7 @@ #include #include +// sync-example-types-def struct Error {}; struct OtherError {}; @@ -27,7 +28,6 @@ struct B {}; struct C {}; struct D {}; -// sync-example-types-def struct UserId {}; struct User {}; struct FilePath {}; From 2883dd7ae1e773240b259d2f2ff6952eb2300439 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sun, 2 Aug 2026 13:11:45 +0100 Subject: [PATCH 11/11] Name the one-order invariant and polish the voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 15 gains the ABI-namespace mechanism under its own name — one normalization order per program — and Section 2's note and README's loud-by-design sentence now point at it. README's What section gives the design document its billing. Subheadings settle on sentence case, choice<>'s incompleteness loses its circular phrasing, and the narrator's "we" goes. Assisted-by: Claude:claude-fable-5 --- README.md | 4 +++- TYPE_ALGEBRA.md | 32 ++++++++++++++++++-------------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index c066995f..cdd0ac13 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,8 @@ The example also demonstrates how well libfn works with general programming idio Beyond the example: `choice` (a monad over `copack`); the same operations over `optional` as over `expected`; simultaneous disjunction (using `operator|` to fallback-combine monadic computations) and its `fn::disjoin` fold; `fn::conjoin` for simultaneous product folds; tuple protocol in `pack` (`get(p)` or structured bindings); `pack` and `copack` are both structural types (a `constexpr` value which may be used as a template parameter); support for immovable values and callables; an extensible pipeline, where a verb defined outside the library pipes exactly like the built-in ones; and more — see [examples/](examples/) and the [API reference][docs]. +None of this is ad hoc: [TYPE_ALGEBRA.md](TYPE_ALGEBRA.md) derives the entire design from first principles — the algebra of products and sums behind `pack` and `copack`, the logic of monadic composition, and the compiler-checked laws that the library obeys. + ## How The library comes as two parts in one repository: @@ -153,7 +155,7 @@ target_link_libraries(main PRIVATE libfn::fn) # or libfn::pfn for the polyfill A third target, `libfn::fn_cxx26`, is the same headers entered with the [`LIBFN_CXX26` mode](#implementation-note) selected; it carries both the mode and its C++26 language requirement, so a target opts in with one link line. Link exactly one of `libfn::fn` or `libfn::fn_cxx26` per target. `pfn` is mode-independent and has no such variant. -With `libfn::fn_cxx26`, a compiler that does not implement `std::type_order` stops at the first libfn header, with an `#error` naming the feature. Mixing the two entry points in one binary stops at the linker, on an undefined reference whose type names differ from the definition's by the `_cxx26` ABI namespace. Both are loud by design: the namespaces are separate so that two layouts cannot merge unnoticed. +With `libfn::fn_cxx26`, a compiler that does not implement `std::type_order` stops at the first libfn header, with an `#error` naming the feature. Mixing the two entry points in one binary stops at the linker, on an undefined reference whose type names differ from the definition's by the `_cxx26` ABI namespace. Both are loud by design: the namespaces are separate so that two layouts cannot merge unnoticed — the invariant [TYPE_ALGEBRA.md](TYPE_ALGEBRA.md) calls one normalization order per program. Packaging is provided — and exercised by CI — for [conan](conanfile.py), [vcpkg](ports/libfn) (an in-repo port), [Nix](flake.nix) and [Bazel](MODULE.bazel); plain CMake `FetchContent` or `add_subdirectory` works as well. Until the first tagged release, consume a pinned git revision — and read [Backwards compatibility](#backwards-compatibility). diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 9280c547..f55af1b0 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -13,7 +13,7 @@ The library operates on two payload types and four computation carriers: Composition operations include `transform` (mapping), `transform_error` (error mapping), `and_then` (sequential monadic binding), `or_else` (recovery), `operator&` (conjunction / simultaneous product composition), `operator|` (disjunction / simultaneous sum composition), and the n-ary folds `fn::conjoin` and `fn::disjoin`. Elimination is `apply` (multidispatch). -### Member vs. Pipeline Syntax +### Member vs. pipeline syntax Some operations are exposed in two forms: @@ -26,9 +26,9 @@ The `operator|` carries two meanings, told apart by its right operand: a pipelin Freestanding `fn::apply(f, args...)` is the general multidispatch entry point: it accepts any mix of scalars, tuple-like structures, `pack`s and `copack`s, unpacking products and dispatching over alternatives in a single call. Do not confuse with `pfn::apply`, which is a polyfill for the C++26 `std::apply`, meant for C++20 compilers. The `fn::apply` is an extension on top of `pfn::apply`. -In prose, we omit prefixes (writing `apply`, `transform`, `and_then`, `expected`, `pack`) when referring to both forms or core vocabulary types generally. +In prose, this document omits prefixes (using `apply`, `transform`, `and_then`, `expected`, `pack`) when referring to both forms or core vocabulary types generally. -### Storage Shape vs. Call Shape +### Storage shape vs. call shape Although different types can behave identically during application, they remain strictly distinct in memory. For example, `pack`, `std::tuple`, and `std::pair` all unpack into the same call shape `f(a, b)` during `apply`, but they are separate C++ types with distinct layouts. Application does not silently convert or unify types on the storage side. @@ -84,7 +84,7 @@ auto graded_pipeline(std::string_view sv) -> void The resulting `expected` statically records that the pipeline yields a `User` on success, or fails with exactly one of `NotANumber`, `OutOfRange`, `Missing`, or `IoError`. This exact union accumulates automatically via `and_then` composition. -### What Does "Graded" Mean? +### What does "graded" mean? Standard monads are rigid: an `expected` requires every step in a pipeline to return the identical error type `E`. This forces you to define a monolithic global error union upfront. @@ -116,7 +116,7 @@ You may also use `copack` on a value side of most carriers (except for `just void > > To enforce set semantics at compile time, `libfn` defines one canonical representation and rejects any instantiation that diverges from it: > -> - **`copack`** is the core storage type. It requires its template parameters to already be flat, unique, and sorted in a strict total order over types. The order is derived from the compiler's own spelling of each type; a build targeting C++26 with `LIBFN_CXX26` set will use `std::type_order` to derive the order of types, while the default build uses a type sorting mechanism based on compiler-specific type names (since these two orders may differ, each defines a distinct ABI namespace). If you attempt to instantiate `copack` manually with out-of-order parameters (such as `copack` when `A` precedes `B` in that order) or with nested copacks (such as `copack>`), the compiler will reject the instantiation as outright ill-formed. +> - **`copack`** is the core storage type. It requires its template parameters to already be flat, unique, and sorted in a strict total order over types. The order is derived from the compiler's own spelling of each type; a build targeting C++26 with `LIBFN_CXX26` set will use `std::type_order` to derive the order of types, while the default build uses a type sorting mechanism based on compiler-specific type names (these two orders may differ; Section 15 explains why they cannot mix). If you attempt to instantiate `copack` manually with out-of-order parameters (such as `copack` when `A` precedes `B` in that order) or with nested copacks (such as `copack>`), the compiler will reject the instantiation as outright ill-formed. > > - **`copack_for`** is the user-facing type alias. It accepts any list of types (out-of-order, duplicates, nested copacks), performs the flattening, deduplication, and canonical sorting, and resolves to the validated `copack`. > @@ -240,7 +240,7 @@ To model computation and manage control flow (success, failure, alternatives, an - **`just`**: Always contains a single successful value of type `T`. - **`choice`**: Always contains one of several selected alternatives, representing the complete state space of the computation. -Because `choice` implies that an alternative is always present, `choice<>` is incomplete: an always-present selected alternative requires at least one alternative to exist. +Because `choice` always holds a selected alternative, `choice<>` is incomplete: it offers nothing to select. Additionally, the infallible state **`expected>`** (representing $T + 0 \cong T$) can never fail because `copack<>` represents the initial zero object **0** (the uninhabited type). Lacking any possible error alternatives, it acts as an infallible, graded unit context. Since it is a specialized state of `expected` rather than a unique template, it is classified under the same computation carrier. @@ -260,7 +260,7 @@ Raw algebraic constructs—such as `std::tuple` (product), `std::variant` (sum), Composing computations requires wrapping these values in computation carriers. Product composition (conjunction) and sum composition (disjunction) operate on carriers, not raw data. The carrier manages success propagation and short-circuits failures. -### Carrier Bridging: Interoperable Pipelines +### Carrier bridging: interoperable pipelines Because these carriers represent different computational contexts, pipelines often need to transition between them. `libfn` licenses explicit **cross-carrier bridging** via pipeline-scoped operations using `operator|`. @@ -465,7 +465,7 @@ The n-ary fold `fn::conjoin(...)` operates in two modes: Mixing carriers and data in a single call is ill-formed. -### Conjunction with the Identity Cluster +### Conjunction with the identity cluster An operand from the identity cluster (Section 10) contributes a value but never a failure: @@ -530,7 +530,7 @@ The runtime semantics are exact: Unlike conjunction, disjunction has no data-level form. Neither `operator|` nor the n-ary fold `fn::disjoin(...)` accepts a `pack`, a `copack`, or a scalar. Therefore, built-in operations (such as bitwise `OR` on integers) are never confused with disjunction. -### Disjunction with the Identity Cluster +### Disjunction with the identity cluster When an operand belongs to the identity cluster (Section 10), the disjunction cannot fail: @@ -739,7 +739,7 @@ auto test_identity_cross() -> void The *bind* operation adopts the carrier family of the provided callback — a crossing only the pipeline-scoped functors are licensed to make (Section 3). -### Success-Path Bridging +### Success-path bridging Fallible carriers (excluding `expected>`) and `optional` cannot transition to infallible carriers, because doing so would risk silently discarding an active error or empty state. @@ -809,7 +809,7 @@ Monadic operations on the identity cluster: `choice` represents a computation that always succeeds by selecting one of several alternatives. Structurally, it serves as the single-layer carrier for coproduct states, avoiding the invalid nested `just>` representation (Section 3). -### Promotion via Pipeline Functors +### Promotion via pipeline functors Pipeline `fn::transform` on a `just` that returns a `copack` promotes automatically to `choice`: @@ -974,7 +974,7 @@ A reference of `libfn` operations, organized by channel and effect: - `fn::conjoin`: An n-ary fold of `operator&`, over monadic carriers or over packs, copacks and scalars — not a mix of the two. - `fn::disjoin`: An n-ary fold of `operator|` over monadic carriers, supporting total disjunction with the identity cluster. -### Key Architectural Rules of the Map +### Key architectural rules of the map - **`fail` and `recover` are duals**: `fail` transitions success to failure ($Success \implies Failure$), and `recover` transitions failure to success ($Failure \implies Success$). Neither operation widens a graded error set. - **Graded `and_then`**: Widens the error grade by introducing new error types into the pipeline. @@ -1016,10 +1016,14 @@ Other properties hold structurally: To ensure reliability, `libfn` uses compiler mechanisms to reject malformed usage and preserve performance. -### Constraints and Exhaustiveness +### Constraints and exhaustiveness Public concepts and `requires` clauses enforce correctness before instantiation. Operations are protected by public applicability concepts (such as `fn::applicable_transform` and `fn::applicable_and_then`) that evaluate to `false` for invalid calls rather than triggering deep compiler errors. This underpins the compile-time exhaustiveness guarantees of `apply` and monadic operations established in Sections 4 and 12. +### One normalization order per program + +The `copack` normal form rests on a strict total order over types, and the default and `LIBFN_CXX26` modes may derive different orders (Section 2). Each mode therefore places `fn` in its own ABI namespace: the mode becomes part of every `fn` symbol, so the two modes never link as one, and `copack`s whose layouts disagree cannot merge unnoticed. (`pfn` carries no such split — it has no normal form to protect.) + ### C++ value properties The library respects C++ value mechanics: