Skip to content

Write the published surface down, so that moving it is a diff - #12

Merged
tamnd merged 1 commit into
mainfrom
stability
Aug 23, 2026
Merged

tamnd merged 1 commit into
mainfrom
stability

Conversation

@tamnd

@tamnd tamnd commented Aug 23, 2026

Copy link
Copy Markdown
Owner

The last item on zu-c's scorecard. stability asks for the tool that tells a reviewer the public surface moved, before a user finds out.

C++ has no such tool in the toolchain. A compiler is perfectly happy to see a method deleted, a parameter added or a noexcept dropped, and the person who finds out is whoever upgrades and rebuilds. What the other clients do about that is keep the surface in a file, so this does the same: zu-node has an api-extractor report, zu-go has an api/surface.txt in the shape of the api/go1.N.txt files the language itself is held to.

What is in it

api/surface.txt is every name include/zu.hpp publishes and the shape it publishes it in, 466 lines, sorted by the name so that everything about one class is one block. It is generated by docs/surface.py out of the same Doxygen run as the reference, and reviewed like any other file.

class zu::Connection
func zu::Connection::appender(std::string_view) -> Appender
func zu::Connection::begin(bool = false) -> void
func static zu::Connection::create(std::string_view) -> Connection
func zu::Connection::raw() const noexcept -> zu_conn *
func explicit zu::Connection::operator bool() const noexcept
using zu::Connection::Progress -> std::function< bool(std::uint64_t rows, std::chrono::milliseconds elapsed)>
enum value zu::Status::ok -> ZU_OK
formatter std::formatter< zu::Status >

The gate rebuilds it from the header and fails when the two disagree, saying what went, what arrived and what changed shape separately, because those are three different pieces of news.

the surface moved: 0 went, 1 changed shape, 0 arrived
  was      func zu::Connection::interrupt() -> void
  is now   func zu::Connection::interrupt() noexcept -> void

cmake --build build --target surface-update writes the file again when the move was deliberate.

What identifies a line

The judgement the whole thing rests on. Identity is what decides which overload a call picks: the name, the types of the parameters, and the const or ref qualifier. Everything else on a line is shape, and shape changing reads as a change rather than as a replacement: the return type, noexcept, = delete, the default arguments. A parameter renamed is not a change at all, because there is no call in C++ that names one.

That is a departure from zu-go's file, which keys on the name alone, and the reason is overloads. to_string is ten functions in this header, and a key that could not tell them apart would report nine of them as gone the day the tenth moved.

A whole parameter added does read as one name going and one arriving, since that genuinely is a different overload. static is in the key for the same reason.

What generating it found

The reference had been missing the entire expected mirror.

EXPAND_ONLY_PREDEF means a #define Doxygen read in the file is not a value it will evaluate an #if against. So every #if ZU_HAS_EXPECTED block was being skipped, and ninety two try_ members were published nowhere. They were warned about nowhere either, because a member the preprocessor threw away cannot be undocumented, which is the same vacuous shape as the include guard that emptied the whole reference two weeks ago. Both feature macros are predefined now. Connection alone goes from 28 published members to 47.

Two comments in the formatter section were being read for the first time as a result, and needed \<format\> and \<ostream\> escaped.

The std::formatter specializations are read out of the header rather than out of the XML, because the Doxyfile expands ZU_FORMATTER to nothing on purpose so that ten macro invocations do not arrive in the reference as ten classes nobody declared. std::format("{}", status) is still a call a user writes.

The gate on the gate

docs/test_surface.py, run by ctest as surface-cases, is the twenty two cases the rules rest on, on XML written out by hand rather than on Doxygen. Getting one of those judgements wrong does not make the gate fail. It makes it pass, or makes it report a renamed parameter as forty names leaving and forty arriving, which is a gate a reviewer learns to skip and comes to the same thing.

Both failure modes were exercised rather than reasoned about. With the include guard back in PREDEFINED the tool reports that nothing at all came out of the XML and names the cause. With the identity rule widened to the whole line, four of the twenty two cases fail.

One more thing worth saying out loud, and it is in the module docstring: Doxygen extracts a member only if somebody documented it, so a member with no comment would be missing from the XML and would be missing from surface.txt, which is a name leaving the file without leaving the header. What catches that is the other gate, WARN_IF_UNDOCUMENTED and reference.py. The two are a pair and turning either one off makes the other quieter than it looks.

Where it runs

CI runs surface-cases before surface, in the existing docs job, since neither needs the engine and the whole job is under a minute. The release runs the gate again and publishes surface.txt beside the reference: two of them from two releases diff into the answer somebody upgrading is actually asking for, which no amount of generated HTML will give them.

The two tests share a resource lock with reference, because both empty the reference directory and run Doxygen into it and ctest -j would have them deleting each other's output.

Verification

44 of 44 green on a Linux box, which is the 41 that were there plus reference, surface and surface-cases. Also configured and run with -DZU_CPP_DOCS=ON and no engine at all, which is the shape CI uses.

The scorecard's stability item asks for the tool that tells a reviewer
the public surface moved, before a user finds out. C++ has no such tool
in the toolchain. A compiler is perfectly happy to see a method deleted,
a parameter added or a noexcept dropped, and the person who finds out is
whoever upgrades and rebuilds.

api/surface.txt is every name include/zu.hpp publishes and the shape it
publishes it in, 466 lines of it, generated by docs/surface.py out of
the same Doxygen run as the reference and reviewed like any other file.
The gate rebuilds it from the header and fails when the two disagree.
It says what went, what arrived and what changed shape separately,
because those are three different pieces of news: a name that arrived is
a minor release and a name that went is not.

Identity is what decides which overload a call picks, so the name, the
parameter types and the const or ref qualifier. Everything else on a
line is shape: the return type, noexcept, = delete, the default
arguments. A parameter renamed is not a change at all, because there is
no call that names one. That is a departure from zu-go's file, which
keys on the name alone, and the reason is overloads: to_string is ten
functions here and a key that could not tell them apart would report
nine of them as gone the day the tenth moved.

Generating it found that the reference had been missing the whole
expected mirror. EXPAND_ONLY_PREDEF means a #define Doxygen read in the
file is not a value it will evaluate an #if against, so every
`#if ZU_HAS_EXPECTED` block was skipped and ninety two try_ members were
published nowhere. They were warned about nowhere either, because a
member the preprocessor threw away cannot be undocumented, which is the
same vacuous shape as the include guard that emptied the reference
before it. Both feature macros are predefined now and Connection alone
goes from 28 published members to 47. Two comments in the formatter
section were being read for the first time and needed \<format\> and
\<ostream\> escaped.

The std::formatter specializations are read out of the header rather
than the XML, because the Doxyfile expands ZU_FORMATTER to nothing on
purpose and std::format("{}", status) is still a call a user writes.

docs/test_surface.py is the twenty two cases the gate rests on, run by
ctest as surface-cases, on XML written out by hand rather than on
Doxygen. Getting one of those judgements wrong does not make the gate
fail, it makes it pass, or makes it report a renamed parameter as forty
names leaving and forty arriving, which is a gate a reviewer learns to
skip and comes to the same thing.

The release publishes surface.txt beside the reference. Two of them from
two releases diff into the answer somebody upgrading is actually asking
for, which no amount of generated HTML will give them.

44 of 44 green on Linux, which is the 41 that were there plus reference,
surface and surface-cases. The two failures worth having were exercised
rather than reasoned about: with the include guard back in PREDEFINED
the surface tool reports that nothing at all came out of the XML and
names the cause, and with the identity rule widened to the whole line
four of the twenty two cases fail.
@tamnd
tamnd merged commit f75672f into main Aug 23, 2026
6 of 11 checks passed
@tamnd
tamnd deleted the stability branch August 23, 2026 05:29
tamnd added a commit to tamnd/zu that referenced this pull request Aug 23, 2026
…667)

tamnd/zu-c#12 landed api/surface.txt and the gate that reads it, so the
last item on zu-c's card is held and the card is full.

The number in the table is 100 rather than 90 because the column is a
percentage of what the client's own tier asks of it, and zu-c is asked
for neither api-map nor perf: it has no second surface to map, the
header is the surface, and the numbers it would publish are the
engine's rather than its own. Ninety points out of a denominator of
ninety is a hundred percent, and it is the first client with nothing
owed.

That makes five tier 1 clients at their tier's threshold, which is the
count the three boxes on DX4 wait on.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant