Skip to content

Generate [expected] wording from the annotated headers via specgen - #94

Draft
steve-downey wants to merge 17 commits into
bemanproject:mainfrom
steve-downey:wording-from-headers
Draft

steve-downey wants to merge 17 commits into
bemanproject:mainfrom
steve-downey:wording-from-headers

Conversation

@steve-downey

Copy link
Copy Markdown
Member

Summary

  • Annotate unexpected.hpp, bad_expected_access.hpp, and expected.hpp with //! specgen docblocks (Effects, Constraints, Mandates, Returns, Throws, Remarks) for every declaration, sourced from the real standard text for already-standardized members and from papers/expected-new.tex for the expected-over-references additions (unexpected<E&>, expected<T&,E>). specgen generate --validate passes cleanly on all three headers.
  • Add papers/wording/generate.sh (wired up as make wording) to turn those docblocks into wording, landed two ways:
    • papers/wording/fragments/*.tex — one file per top-level clause, for \input into a standalone paper.
    • papers/wording/expected.tex — the same content concatenated in real standard clause order at the draft's own absolute numbering, with no \rSec2[expected] wrapper and no \input directives — the basis for a patch to source/utilities.tex in the actual C++ working draft (cplusplus/draft), where [expected.general] and [expected.syn] stay untouched (hand-authored prose, not generated from any one declaration) and only the subclauses from [expected.unexpected] on are replaced/extended.

The headers are now a source of truth specgen can turn into wording directly, instead of the wording and the implementation being able to drift apart.

Test plan

  • specgen generate --validate passes on all three headers
  • cmake --build --preset gcc-debug
  • ctest --preset gcc-debug — 1178/1178 tests pass
  • Reviewer spot-check: does papers/wording/expected.tex read correctly against the current [expected] clause and papers/expected-new.tex?

catch_discover_tests defaults to DISCOVERY_MODE POST_BUILD, which runs
the freshly-linked (ASan-instrumented) test binaries during the ninja
build itself to enumerate their test cases. Under the CodeQL Advanced
workflow, CodeQL's build tracer injects its own LD_PRELOAD ahead of
the ASan runtime, and ASan aborts immediately with "ASan runtime does
not come first in initial library list", failing the build before
analysis can run.

Switching to DISCOVERY_MODE PRE_TEST moves the enumeration step to
ctest invocation time instead of build time, so no instrumented binary
runs while CodeQL is tracing the build. Verified locally: gcc-debug
preset builds cleanly and `ctest` still discovers and passes all 1178
tests.
fix: defer Catch2 test discovery to ctest time to unblock CodeQL build
Add //! docblocks (Effects, Constraints, Mandates, Returns, Throws,
Remarks) to every declaration in unexpected.hpp, bad_expected_access.hpp,
and expected.hpp, sourced from the real standard text for the
already-standardized members and from papers/expected-new.tex for the
expected-over-references additions (unexpected<E&>, expected<T&,E>).
specgen generate --validate passes cleanly on all three headers, so the
headers are now a source of truth specgen can turn into wording directly.
Add papers/wording/generate.sh (wired up as `make wording`), which runs
specgen against the three headers and assembles the result two ways:

- papers/wording/fragments/*.tex: one file per top-level clause, for
  \input into a standalone paper (specgen numbers a fragment's \rSec
  markers one level deeper than written, so a paper's own
  \rSec1[expected]{Expected objects} supplies the level these assume).
- papers/wording/expected.tex: the same content concatenated in real
  standard clause order, at the draft's own absolute numbering, with no
  \rSec2[expected] wrapper and no \input directives -- the basis for a
  patch to source/utilities.tex in the actual C++ working draft
  (github.com/cplusplus/draft), where [expected.general] and
  [expected.syn] are untouched and only the subclauses from
  [expected.unexpected] on are replaced/extended.

[expected.general] and [expected.syn] are prose, not generated from any
one declaration; they stay hand-authored in papers/expected-new.tex.
Comment thread papers/wording/expected.tex Outdated
\returns
\tcode{!x.has_value() && static_cast<bool>(x.error() == e.error())}.
\end{itemdescr}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[pre-commit] reported by reviewdog 🐶

Suggested change

@github-advanced-security

Copy link
Copy Markdown
Contributor

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

…s document

A newer specgen enforces that a name used in one generate invocation's
wording must be documented in that same run, which broke generating
expected.hpp on its own: it uses unexpected, unexpect, unexpect_t,
reference_constructs_from_temporary_v, and bad_expected_access, all
declared in the other two headers.

Wrap expected.hpp's existing #includes of unexpected.hpp and
bad_expected_access.hpp in a gathered \rSec2[expected.syn] region so
specgen treats all three headers as one document, and add a throwaway
\rSec2[expected.detail] marker so the exposition-only helper templates
above [expected.expected] don't bleed into [expected.bad]'s fragment
once nothing else is there to close the section. generate.sh now runs
specgen once on expected.hpp instead of three times, mapping the same
six clause fragments as before plus the two new non-clause fragments,
excluded exactly like the previous per-header root fragments were.

unexpected.tex/bad.tex/bad-void.tex regenerate byte-identical to the
prior per-header runs; object.tex/void.tex/ref.tex pick up only this
specgen version's own docblock-element reordering, confirmed by equal
itemdecl counts before and after.
…ected.tex

The per-fragment loop echoed a separator after every fragment, including
the last, leaving a second trailing newline that pre-commit's
end-of-file-fixer then had to strip on every regeneration. Insert the
separator between fragments instead.
@steve-downey
steve-downey marked this pull request as draft September 10, 2026 11:54
generate.sh relied on specgen's hard-coded latex base depth of 3, and the
banner and README rationalized the result as "one level deeper than
written, matching the real standard's absolute numbering". It does not
match: in source/utilities.tex [expected] is \rSec1 and
[expected.unexpected] is \rSec2, so emitting [expected.unexpected] as
\rSec3 and its subclauses as \rSec4 numbered them one level too deep for
both consumers -- a patch against the draft, and an \input into a paper
whose own \rSec1[expected] makes its sibling clauses \rSec2.

specgen now reaches that depth from the command line (specgen#97), so
pass --base-section-depth 2 and drop the compensation from the prose.
Every \rSec marker a clause writes in a header now means what it says.
Regenerating changes nothing but the heading levels: the stable names and
their nesting are unchanged, and every clause the draft already has now
sits at exactly the draft's own level.

The other two specgen workarounds stay, both still load-bearing:
- the gathered \rSec2[expected.syn] region over expected.hpp's #includes.
  specgen#109 (validate across the union of a paper's documents) removes
  the unexpected/bad_expected_access findings, but a split run still
  reports unexpect and unexpect_t (\omit'd here, since they belong to the
  hand-authored [expected.syn]) and reference_constructs_from_temporary_v
  (a using-declaration, which contributes no documented name) as foreign.
- the throwaway \rSec2[expected.detail] marker. Without it the five
  exposition-only helpers above [expected.expected] still bleed into
  [expected.bad]'s fragment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
steve-downey and others added 10 commits September 16, 2026 22:32
2b737a4 left two workarounds in place and spelled out why each was still
load-bearing. specgen has since closed both underlying issues, so both
come out.

specgen#113 (PR #116) adds a sibling document's \elsewhere and
exposition-only using-declared names to the paper-wide documented set, so
a split run no longer reports unexpect, unexpect_t, or
reference_constructs_from_temporary_v as foreign. The gathered
\rSec2[expected.syn] region over expected.hpp's #includes is gone:
generate.sh now emits one IR per header and renders the three together
with --from-ir, so validation still sees the union of documented names.

specgen#114 (PR #115) stops a declaration between two clauses landing in
the preceding one, so the throwaway \rSec2[expected.detail] marker is
gone too. The exposition-only helpers land in the root fragments, which
generate.sh already discards: expected.root.tex holds
converts-from-any-cvref, is-expected-specialization and
unexpect-dangles-v, expected.unexpected.root.tex holds
is-unexpected-specialization. No helper leaks into a kept fragment.

Two marker corrections fall out of #116 actually acting on these
declarations:

- unexpect_t and unexpect move from \omit to \elsewhere. They belong to
  the hand-authored [expected.syn], and \elsewhere is what promises that
  to a sibling document rather than hiding them outright.
- reference_constructs_from_temporary_v moves from \expos to \elsewhere
  on both arms of its #ifdef. \expos on a using-declaration used to be a
  no-op; now that it contributes a name, every use started rendering as
  an exposition-only *reference-constructs-from-temporary-v*. It is the
  real std:: trait from [meta.rel], not a library invention, so the
  wording has to keep the plain spelling. With \elsewhere,
  fragments/unexpected.tex regenerates byte-identical to before.

Also reattach [expected.ref.assign]'s trivial copy assignment docblock. A
blank line had crept in between it and the declaration, silently dropping
the whole itemdescr -- five paragraphs, no diagnostic. Moving the
"// Copy assignment (trivial path)" comment above the docblock, as the
non-trivial path a few lines down already does, restores them, and also
stops that comment being swallowed into the end of the Remarks prose,
which is how it read before.

Regenerating leaves the section set, the itemdecl count (167) and the
itemdescr count (171) unchanged. A sorted-line diff against the previous
wording is exactly: the two [expected.ref.assign] \remarks merged into
one, the swallowed-comment paragraph gone, and "// Copy assignment
(trivial path)" added to the synopsis. The rest of the diff is
declaration reordering from the #114 fix. make papers still builds
D4280R0.pdf, 116 pages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
papers/wording/generate.sh existed because specgen could not express a
three-header paper in one command: it ran three `generate --emit-ir`
invocations into a temporary directory, one `render --split`, renamed the
fragments, dropped the ones it did not want, and concatenated the rest. And
nothing depended on any of it -- `wording` was .PHONY with no prerequisites and
`papers` did not depend on it at all, so the PDF could be built from wording
three commits stale and look exactly like a PDF built from current wording.

specgen now takes the whole paper in one invocation and reports what it read,
so the script becomes a makefile rule with real prerequisites:

- one `specgen generate` over all three headers, so `--validate` sees the
  paper-wide union of documented names in a single parse (~2s);
- `--depfile` writes what it read to papers/.deps/wording.d, which the
  makefile `-include`s. That list has include/beman/expected/config.hpp in
  it -- a header none of the three names on the command line, which the old
  script had no way to know about and which a hand-written prerequisite list
  would forget;
- `make papers` now depends on `wording`, closing the last open edge:
  headers -> wording -> PDF.

The fragments keep their stable names (expected.bad.void.tex rather than
bad-void.tex), which deletes the rename step; nothing `\input`s them, so this
costs nothing. The regenerated fragments are byte-identical to the ones they
replace, and expected.tex differs only in the comment at its top, which now
lives in papers/wording/preamble.tex.

What did not move into specgen is the clause order. bad_expected_access<void>
is the base class of bad_expected_access<E>, so the header has to define the
specialization first, while the draft states the primary template first --
the paper's order is not the headers' and cannot be derived from them.
$(WORDING_CLAUSES) is where that one divergence is now written down, instead
of being implicit in the order a script happened to cat six files.

Requires specgen with multi-header `generate` and `--depfile`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`-include papers/.deps/*.d` is a bare glob, unlike the three other depfile
includes in this makefile, which all wrap theirs in $(wildcard). Two things
follow from that.

When the glob matches nothing, make keeps it as a literal target name and tries
to remake it -- through `.DEFAULT`, which hands it to cmake. The depfile is only
written by an actual regeneration, so on a fresh clone, where the committed
fragments are already up to date, it is never written: every `make` invocation,
`make help` included, first runs `uv run cmake --build ... --target
'papers/.deps/*.d'` and prints an error about it. `-include` swallows the
failure, so nothing breaks; it is just noise that never goes away on its own.

And papers/.deps/ is not ours alone -- papers/Makefile already points latexmk's
-deps-out at it. Once anyone builds the PDF, the glob pulls D4280R0.pdf.d into
the top-level makefile, where its papers/-relative paths (../include/...,
./wg21.bib) resolve against the repository root instead: 293 phantom targets at
paths that do not exist, and `make D4280R0.pdf` at the root answering "nothing
to be done" rather than falling through to cmake.

$(wildcard) fixes the first, naming wording.d fixes the second.

Also record the GNU Make floor in papers/wording/README.md. The one-invocation
rule is a grouped target, which 4.3 introduced; 4.2 parses `&:` as something
else and would run specgen once per fragment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
specgen's collect_inclass_items previously earned a defaulted/deleted
member a standalone itemdecl whenever its derived elements were
non-empty, but a template-head requires-clause now derives a
Constraints element even when nothing was authored — so a deleted
overload with only that derivation got a standalone item that just
restated its own declaration. Fixed upstream in specgen (issue #121);
this regenerates papers/wording/ against the corrected binary.

The requires-clause is also now stripped from each item declaration in
favor of the derived Constraints paragraph, matching the draft's own
style, which accounts for most of the diff.
…nce-E converting copy/move constructors

expected(const expected<U,G>&) and expected(expected<U,G>&&), for the
overload set enabled when E is a reference type, require
is_constructible_v<T, const U&> / is_constructible_v<T, U&&> in code
(cvt-copy-ctor-ref's requires-clause) but the authored Constraints
element never said so — only is_reference_v<G> and is_convertible_v<G,
E> were documented. Nothing else in the docblock accounts for T's
constructibility from U, unlike is_reference_v<E>, which the existing
Remarks paragraph already covers in prose. Add the missing conjunct,
worded the way the sibling value-E overload group above states the
same kind of constraint.
…erenced type

unexpected<E&>'s class-level Mandates only named an array type or a
specialization of unexpected as ill-formed instantiations, but the
class also static_asserts is_object_v<E> — banning a function-type
referent (e.g. unexpected<F&> for a function type F) — which the prose
never mentioned. The primary unexpected<E> template's Mandates already
lists the equivalent non-object-type case; the E& partial
specialization's didn't carry it over.
…ates

expected<T&,E>'s class-level Mandates said only "T shall be an object
type that is not an array type," but the class also static_asserts
that T is not in_place_t, not unexpect_t, and not a specialization of
unexpected -- three real restrictions the prose never mentioned. All
three are object types and non-array, so the existing wording wouldn't
have excluded them. The primary expected<T,E> template's Mandates
already lists the equivalent exclusions for its value type; this
specialization's didn't carry them over.
…ference-error unexpected constructor/assignment

ref-cvt-unexpected-ctor-ref and ref-cvt-unexpected-assign-ref both
documented is_convertible_v<G, E> as the Constraints, but their
requires-clauses actually gate on is_constructible_v<E, G>;
is_convertible_v<G, E> only governs whether the same declarations are
explicit (the explicit(...) specifier just above each). Since
is_convertible implies is_constructible but not the reverse, the
wording as written over-restricted participation: a G for which E has
only an explicit converting constructor would read as excluded even
though the code accepts it. The identically-shaped pairs in the
primary template (cvt-unexpected-ctor-ref, cvt-unexpected-assign-ref)
and the void specialization already state the correct predicate;
only the T& specialization had it wrong, in both places.
…uctor overload split

ref-cvt-copy-ctor-ref documented is_reference_v<E> nowhere, unlike
every other reference-E-gated overload in this specialization and its
sibling in the primary template's cvt-copy-ctor-ref, both of which
carry a Remarks paragraph saying the overload participates only when
E and G are both reference types. Not a wording error -- the
Constraints element is accurate either way -- but the omission was an
inconsistency against the paper's own established convention of
explaining the split instead of restating is_reference_v<E> in every
Constraints. Add the matching Remarks.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants