Skip to content

core/ladder: retire IBackend's four async twins and move the examples' prose onto the structural surface (fixes #570, fixes #571) - #649

Merged
Yaraslaut merged 5 commits into
masterfrom
laneLADDER-batch-570-571
Sep 21, 2026
Merged

Yaraslaut merged 5 commits into
masterfrom
laneLADDER-batch-570-571

Conversation

@Yaraslaut

@Yaraslaut Yaraslaut commented Sep 20, 2026

Copy link
Copy Markdown
Member

The tail of the #522 registration-surface chain: #570 then #571, in that order, because the second cannot land until the first has moved the last consumers. Three commits — one per ticket, plus a separate, clearly-labelled one for the two line-hint gates.

Closes #570. Closes #571. Closes #522 (see the caveat under "What this does not claim").


1 — #570: the examples were already on the surface; the prose was not

6cdff61d

The ticket reads as a code migration. It is not one, and that is worth stating plainly rather than quietly delivering a documentation change against a ticket that asked for something else.

Nothing under examples/ has ever called registerModelAsync, registerModelSharedAsync, attachModelAsync or assignPrimaryAsync. Application code reaches a backend only through Bridge, and #568 moved Bridge's four dispatch sites onto bindModel/promoteModel. Checked rather than assumed: all 25 hits under examples/ on c55ea5b7 were comments//, ///, a CMake #, or Markdown.

So this commit moves prose that named verbs nothing had implemented since #568 and that commit 2 deletes:

  • The GUI clients and AppContext now name QtWebSocketBackend::bindModel() as what queues a private bind issued before the socket connects. The described behaviour is unchanged and still true (src/qt/qt_websocket_backend.cpp, the !_connected arms: a request with an empty primary is queued and sent on the next connected; one with a non-empty primary is rejected with "disconnected").
  • AppContext's readiness contract gains the half it was missing. With asyncRegistrationEnabled the backend answers kCallerMustNotBlock, so registerHandler hands back an unbound handler and the caller must gate on whenBound()/isBound(). That was already true; the doc comment described only the queue.
  • polls/README.md's struck assignPrimaryAsync claim keeps its correction but states it against IBackend::promoteModel, which no backend can decline — a stronger version of the same rebuttal.
  • LADDER.md, TESTING.md and polls/README.md keep their "shipped" history: each says what shipped then and that core: give IBackend a registration surface whose threading contract is structural, not prose #567core: remove IBackend's four async twins and retire the prose threading contract #571 replaced it, rather than deleting the record.

examples/common/testkit/test_wasm_registration_path_native.cpp still pins the single-threaded registration path through Bridge — only its comment moved, and its citation of tests/qt/test_qt_websocket.cpp now names that file's current test name.

2 — #571: the four twins, and the prose contract they carried

085b401b

$ grep -rn 'registerModelAsync\|registerModelSharedAsync\|attachModelAsync\|assignPrimaryAsync' include src tests examples
$ echo $?
1

Removed from IBackend and from SynchronousBackendAdapter's forwarding block; Bridge's four "offer the twin, fall back to bindModel" branches are now one unconditional dispatch each.

Virtual count — and #522's prediction, which was wrong

struct IBackend, excluding the destructor:

virtuals pure defaulted
before (c55ea5b7) 21 5 16
after (085b401b) 17 5 12

#522 says "18 virtuals with 14 defaulted" and predicts the interface "roughly halves". The baseline was already stale (there were 21 once bindModel/promoteModel/bindWaitPolicy landed), and 21 → 17 is a 19% reduction, not a halving. It could never have been one: the four twins were the only things this set removes, and the synchronous verbs they sat beside all remain, because bindModelBlocking dispatches to them by request shape — which is what lets an un-migrated backend keep working. Full before/after name lists are on #522.

What the twins carried that the surface does not

One thing, named rather than left to be found. A bool twin handed Bridge two raw std::functions, so a backend firing twice reached detail::parkIfInFrame's double-claim guard. A Completion cannot be settled twice — CompletionState drops the second settle before any Bridge code sees it — so DoubleFiringBackend now pins the observable contract ("exactly one onDone") while that guard is no longer reachable from a backend. Filed as #648, with the smaller sibling case (the catch (...) around the dispatch, now reachable in-tree only from a test double).

The prose threading contract is relocated, not deleted

The @note shared by the four twins asked every backend author to deliver from a thread on which ~Bridge could not run concurrently — #486's use-after-free. That reasoning now lives in docs/spec/core/backend.md under "What was wrong with the old shape" (what the contract was, and why registerHandlerImpl was the one site that did not depend on it) and "How the threading contract becomes structural" (what replaced it), plus docs/spec/concurrency_and_lifetimes.md's continuation bullet.

Per the runner decision recorded on #571, the claim is stated narrowly in all three places and in bridge.hpp's call-site comments: the guarantee is structural for backends; Bridge's four dispatch sites still name exec::detail::inlineExecutor(), which reproduces the old delivery thread exactly, so #486's window is unchanged, not closed. Closing it is #588 and is not here.

The ticket's "Also in scope" list is stale, and is not acted on

It asks for LocalBackend, SimulatedRemoteBackend and "the 11 test doubles" in test_switch_backend.cpp, test_bridge_lifetime.cpp and test_client_execute_deadline.cpp to be wrapped in SynchronousBackendAdapter. Measured on c55ea5b7:

$ grep -c 'registerModelAsync\|registerModelSharedAsync\|attachModelAsync\|assignPrimaryAsync' \
    tests/test_switch_backend.cpp tests/test_bridge_lifetime.cpp tests/test_client_execute_deadline.cpp
tests/test_client_execute_deadline.cpp:0
tests/test_bridge_lifetime.cpp:0
tests/test_switch_backend.cpp:0

None of them — nor LocalBackend, nor SimulatedRemoteBackend — overrides a twin, so none needs migrating: bindModel's default already routes them. Wrapping them would be a regression: the adapter answers kCallerMustNotBlock unconditionally, which would make registerHandler return an unbound handler to every in-process embedder — the exact regression #593 undid for SocketBackend. Full reasoning on #571.

Doubles that were migrated

tests/test_async_registration.cpp. AsyncRegisterBackend overrides bindModel (one verb, all three acquire shapes, deferred into the same completeNext()/failNext() queue) and answers kCallerMustNotBlock, which is what reproduces "dispatch and return without waiting". InlineCompletingBackend, ThrowingDispatchBackend, DoubleFiringBackend and AsyncBackendShim follow; AsyncAssignPrimaryBackend and SelfFiringAssignPrimaryBackend move to promoteModel.

One detail the twins hid, found by a test failing rather than by reading: ThrowingDispatchBackend must tell its two arms apart by request.primary, not request.currentattachHandlerAsync's first attach carries a zero current, so both tests landed on the same arm. tests/test_backend_registration_surface.cpp's RecordingBackend drops the twins and their four forwarding assertions; the adapter's forwarding test still pins every synchronous verb.

Spec section rename

backend.md's "Asynchronous registration — registerModelAsync" becomes "Why registration needs a non-blocking path", keeping the live content (the nested-QEventLoop/WASM rationale, the asyncRegistrationEnabled gate, the pre-connect queue, what an unbound handler means) and handing the history to "What was wrong with the old shape". Every citation is repointed: locality.md, bridge.md, shared_instances.md, three sites in include/morph/core/bridge.hpp, and three examples/ files. scripts/test_check_spec_citations.sh's rename fixture is repointed too, and its self-test still catches all sixteen drifts it claims to.

Two places still name the removed verbs, deliberately: backend.md's one paragraph saying what was removed, and shared_instances.md's one-line pointer to it. A spec that cannot name what it removed cannot explain the removal. The grep acceptance criterion is scoped to include src tests examples, and that is clean.

3 — gates

50a8569d, separate so no ticket's diff carries a number nobody read the code for.

scripts/mutation_survivors.json: three backend.hpp citations, two of them reported as "appears 2 times … not decidable" exactly as those entries' own reason text predicted. Resolved by reading both candidates, not by taking the first — registerCount1031 (LocalBackend::registerModel, not 1050's registerModelShared arm, which the reason names as the one it is not); executeInFlight1195 (immediately after fetch_add, not 1240's fetch_sub); reserve → 1138, unambiguous.

scripts/branch_partial_allowlist.json: backend.hpp:1414 → 1140, bridge.hpp:1551 → 1540, bridge.hpp:1673 → 1662. The last is ambiguous three ways; 1662 is the catch (...) block in executeVia, which is what that entry's reason describes, and the parenthetical naming the other two (1697, 1786) is updated to 1686, 1775.

One hit not edited: mutation_survivors.json's classification_2026_09_09 sample citing bridge.hpp:584 and started = backend->attachModelAsync(...). That is a dated record of a measurement, not a description of the tree; rewriting it would destroy the record. A note saying so is added beside the verdict.


Review reasoning (done inline — no /code-review, no /simplify)

  • Is the deletion safe? The one claim the branch rests on: since qt: move QtWebSocketBackend onto the structural registration surface and delete the WASM special case #568 no backend in the tree overrode any twin, so every twin call returned false and every dispatch site already took the bindModel branch. Verified by grep on c55ea5b7 — the only overriders left were test doubles. Deleting a branch that never ran therefore changes no production path.
  • Did anything only compile because a defaulted virtual existed? SynchronousBackendAdapter's four forwards, removed with them. Nothing else: the full-tree grep is empty and the build is clean with -Werror.
  • The try/catch in attachHandlerAsync/ensureBoundAsync is kept, though bindModel's default rejects rather than throws. Removing it would be a behaviour change for an out-of-tree override, outside this ticket. Its comment now says what it actually guards. Recorded in core: parkIfInFrame's double-claim guard is unreachable from a backend now that every dispatch goes through one Completion #648.
  • bindWaitPolicy forwarding in AsyncBackendShim is not cosmetic: without it the shim would answer the default kCallerMayBlock while its target defers forever, and switchBackend's phase 1 would park. Called out in the code.

What this does not claim

Verification actually performed

Linux, GCC 16.2.1, Debug, -DMORPH_BUILD_TESTS=ON -DMORPH_BUILD_EXAMPLES=ON -DMORPH_BUILD_QT=ON -DMORPH_BUILD_NET=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all, build clean.

tests/morph_tests                          22931 assertions in 1556 cases   (1 failed as expected)
tests/qt/morph_qt_tests                      578 assertions in   79 cases
tests/net/morph_net_tests                   1112 assertions in  191 cases
tests/net_qt_interop/..._interop_tests         9 assertions in    2 cases
examples/common/ladder_common_tests          587 assertions in  153 cases
$ bash scripts/check_spec_citations.sh
Prose lint OK: every pinned fact is still cited; no banned terminology found; every cited path resolves; every cited section exists; every forms key and rule kind is documented.

$ bash scripts/test_check_spec_citations.sh
scripts/check_spec_citations.sh detects every section-citation and forms-vocabulary drift it claims to.

$ python3 scripts/check_mutation_survivors.py
ok: 15 structured citation(s) in scripts/mutation_survivors.json resolve to the line they name.

$ git diff --name-only origin/master HEAD | bash scripts/check_spec_sync.sh
Spec sync OK: 10 sub-domain(s) classified; every touched header sub-domain has a matching spec change.

$ bash scripts/check_catch_test_names.sh
checked 2986 test-case name(s) in 707 file(s)
Catch2 test-name lint OK: every discovered test name selects itself as a filter.

$ bash scripts/check_ctest_name_collisions.sh build
ok: 2886 ctest test names under build are unique

$ bash scripts/check_nolint_directives.sh
NOLINT directive lint OK: 164 NOLINTNEXTLINE directive(s), all annotating code.

$ bash scripts/check_rung_filters.sh
All 49 rung-filter checks passed.

$ bash scripts/check_tidy_suppression_scope.sh
ok: tests/.clang-tidy records the reach it actually has

Doxygen with WARN_AS_ERROR = FAIL_ON_WARNINGS builds clean (-DMORPH_BUILD_DOCUMENTATION=ON, target doc). clang-format --dry-run -Werror clean over every changed C++ file — with the caveat that the local binary is 22.1.8 and CI pins 20; I checked that v22 reports the unmodified tests/test_async_registration.cpp from origin/master as already clean, so the two agree at least on that file.

The branch-coverage and error-path allowlists' line hints were audited by calling check_branch_coverage.resolve_allowlist_source_line over every entry of both files: 0 failures each. Their coverage halves need build/clang-coverage/coverage.lcov, which was not produced.

Issues filed


4 — rebase onto f7c231df, and the two things that made clang-tidy-diff red

Rebased from c55ea5b7 onto f7c231df before anything else: #647 landed and
changed the very leg this section fixes — the clang-tidy job now builds two
*_autogen targets so the AUTOMOC self-including sources parse, and asserts
every self-included .moc exists. A CI result judged against the old base
would not have meant anything. Rebase was clean; no conflicts.

The failed run (35544723152) stalled with Linux / all optional features (clang) never starting, so its log is unreachable. The clang-tidy-report
artifact is the evidence, and it is reproduced below locally rather than taken
on trust.

4a — bugprone-exception-escape: a real finding, fixed on its merits

a7f8aba9

tests/test_async_registration.cpp:582:5: error: an exception may be thrown in
function '~SelfFiringAssignPrimaryBackend' which should not throw exceptions
[bugprone-exception-escape,-warnings-as-errors]
  note: frame #0: unhandled exception of type 'bad_weak_ptr' may be thrown in
        function '__throw_bad_weak_ptr' here
  note: frame #5: function 'setValue' calls function 'shared_from_this' here
                                      include/morph/core/completion.hpp:108
  note: frame #6: function 'resolve' calls function 'setValue' here
  note: frame #7: function '~SelfFiringAssignPrimaryBackend' calls function
        'resolve' here

This double settles its one still-pending ModelCompletion from inside its
own destructor — the only way to reach assignHandlerPrimary's !pinned arm.
A destructor is implicitly noexcept, so an escape is std::terminate with
no attribution, not a failed assertion.

The named bad_weak_ptr is a static over-approximation: the Promise holds a
shared_ptr to the state, so shared_from_this() cannot fail here. The
obligation is not an over-approximation
, because setValue also runs the
continuation Bridge::assignHandlerPrimary attached, inline, on this thread,
and that is ordinary caller code.

So: try/catch (...), and the catch records into an std::exception_ptr
the test owns and CHECKs after the backend is gone. A NOLINT would have
kept the terminate; a bare catch (...) {} would have turned it into
silence. The new CHECK is what stops the arm being a swallow.

Not bundled with 4b's suppression, deliberately — that is how a real defect
gets hidden behind an environmental one.

4b — the five TUs this configure cannot build: the gate is wrong, not the diff

3c6dd24f, .github/workflows/ci.yml only.

The other 21 findings were all clang-diagnostic-error, from five changed
sources this branch edits in comments only. A changed comment line is a
changed line, so clang-tidy-diff analysed five TUs nothing had ever analysed:
four WASM mains needing an Emscripten/Qt-WASM toolchain the job does not
configure, and
tests/compile_checks/client_only_facade_no_model_header.cpp, which is built
by a configure-time try_run() with -DMORPH_CLIENT_ONLY and deliberately
omits model.hpp — the artifact's note chain resolves every bridge.hpp,
registry.hpp and model.hpp error to that one TU.

Reverting the comment edits was checked and is not available. Those four
WASM comments cite a docs/spec/core/backend.md section this branch renames,
so leaving them at their old text turns the spec-citation gate red instead —
measured, by actually reverting them and running the script:

::error file=examples/bookmarks/gui_wasm/main_wasm.cpp,line=88::dangling section citation: docs/spec/core/backend.md has no section "Asynchronous registration"
::error file=examples/pastebin/gui_wasm/main_wasm.cpp,line=78::dangling section citation: ...
::error file=examples/polls/gui_wasm/main_wasm.cpp,line=254::dangling section citation: docs/spec/core/backend.md has no section "Asynchronous registration"
::error file=examples/polls/gui_wasm/main_wasm.cpp,line=254::dangling section citation: docs/spec/core/backend.md has no section "Shared/keyed registration"

This is the third instance of one structural problem (#624 a generated
header, #650 tests/lint/ fixtures, this), so the fix generalises rather than
special-casing five paths. The diff is filtered before clang-tidy-diff.py
sees it:

What keeps the filter from being a control that measures nothing. It
refuses to run unless the database is still the wide one the Configure step
builds: ≥600 in-workspace sources, ≥200 under examples/. Counting only paths
that resolve inside the workspace means a resolution mismatch trips the
floor too, instead of silently skipping everything. Measured here: 703 entries
naming 695 distinct in-workspace sources, 270 under examples/; CI measured
690/276 at #481's revision. The regression the floors exist to catch —
MORPH_BUILD_LADDER back to its OFF default — takes examples/ to 16, not to
199.

No path list is hard-coded anywhere; the exclusion is derived from the tree on
every run.

Verification for section 4 — measured, with the flag set that matters

Everything below ran the gate itself, not a bare clang-tidy file.cpp -- -std=c++23 (which enables none of the warnings CI builds with and reports
clean on broken code). That means: this job's exact configure
(clang-debug + the nine MORPH_BUILD_* flags), the two *_autogen targets
#647 added, clang-tidy-diff.py, clang-tidy 22.1.8 (CI's pinned major),
-p1 -extra-arg=-std=c++23 -extra-arg=-Wno-missing-include-dirs -quiet.
Findings are recognisable by ending [check-name,-warnings-as-errors].

result
gate on the branch before these two commits exit 1, 22 findings: 21 clang-diagnostic-error from the 5 unbuilt TUs + 1 bugprone-exception-escape
gate on the branch after exit 0, no findings; 26 of 31 changed file section(s) analysed, 5 source(s) skipped as unbuilt here
mutation — unguarded destructor re-introduced, filter still on exit 1, bugprone-exception-escape at the moved line 609
Python embedded in the YAML, extracted back out of the parsed workflow byte-identical filtered diff to the standalone version tested
bash -n on the edited step clean
check_workflow_job_banners.py ok: all 24 section banner(s) introduce the job they describe
check_ci_clang_pin.sh ok: all 7 CI-clang-pin assertion(s) agree with .github/workflows/ci.yml
check_workflow_option_coverage.py 16 MORPH_BUILD_* option(s) declared, all accounted for
check_spec_citations.sh Prose lint OK: ...
clang-format --dry-run -Werror on the changed test clean
tests/morph_tests (clang 22 Debug) 22932 assertions in 1556 cases — the branch's 22931 plus this commit's one CHECK1 failed as expected

Not verified, plainly: no Emscripten toolchain here, so nothing was built
for the four WASM mains — the only claim made about them is that this job
cannot analyse them, which is what their absence from compile_commands.json
shows. The workflow step was validated as its parts (bash -n, the extracted
Python, the pipeline) rather than executed as one script. Everything section
3's "What this does not claim" already said still stands.

Issues filed for section 4


🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner verification — the conclusion holds, the stated inventory does not

The claim this branch rests on: "Since #568, no backend in the tree overrode any of the four twins … the only overriders left were test doubles in tests/test_async_registration.cpp and tests/test_backend_registration_surface.cpp."

The inventory is incomplete. A shipped type overrides all three twins:

class owning line 1049 -> 913: class SynchronousBackendAdapter : public detail::IBackend {

include/morph/core/backend.hpp:1049:    bool registerModelAsync(…) override {
include/morph/core/backend.hpp:1067:    bool registerModelSharedAsync(…) override {
include/morph/core/backend.hpp:1084:    bool attachModelAsync(…) override {

SynchronousBackendAdapter is production code, not a test double. My first attempt to check this missed it too — I grepped for override on the same line as the name, and these signatures wrap, so the grep returned nothing and proved nothing.

But the conclusion survives, because the override is a pass-through:

bool registerModelAsync(…) override {
    return _inner->registerModelAsync(typeId, std::move(factory), contextKey,
                                      std::move(onRegistered), std::move(onError));
}

It returns whatever the wrapped backend returned, so it cannot make the twin branch run unless something beneath it returns true. Nothing does:

$ # twin declarations anywhere in the tree, any extension, at c55ea5b7
  none outside backend.hpp + tests/test_async_registration.cpp
                           + tests/test_backend_registration_surface.cpp

$ # the two production remote backends
  include/morph/net/socket_backend.hpp            0 occurrences
  include/morph/qt/qt_websocket_backend.hpp       0 occurrences

#586/#587 and #585 had already moved both onto the structural surface. So every twin call in production reached the base default and returned false, every dispatch site took the bindModel branch, and deleting a branch that never ran changes no production path — which is the claim as it matters. Confirmed.

Worth restating precisely, because the difference will matter to the next reader: the correct statement is not "nothing overrides the twins" but "nothing implements them — the one production override forwards, and no forwardee implements them either." A forwarder in the inventory is not a counterexample, but leaving it out makes the claim look falsifiable by a one-line grep that anybody will run.

The examples/ finding checks out, and it is the more interesting half of #570: all occurrences under examples/ are prose. Spot-checked four files — bookmarks/gui/main.cpp:98, common/gui/app_context.hpp:62, common/testkit/test_wasm_registration_path_native.cpp:39,41, polls/gui_wasm/main_wasm.cpp:136,252 — every one is a //, /// or Markdown line describing QtWebSocketBackend::registerModelAsync(), a method that stopped existing in that form at #585. So #570 was indeed a documentation ticket wearing a migration ticket's clothes, and saying so beats quietly shipping a no-op migration.

Accepting both judgement calls. Keeping the removed verbs named in backend.md's "What was wrong with the old shape" is right — a spec that cannot name what it removed cannot explain the removal, and #571's acceptance grep is scoped to include src tests examples, which is clean. The section rename with its 10 repointed citations is the kind of churn that is cheaper now than later.

Not merged: CI incomplete at hand-off. The legs to watch are the two the lane could not run locally — Build the ladder's WASM clients and Build & deploy WASM demo — plus clang-format for the 22-vs-20 skew, and Linux / clang-coverage for the coverage halves of the two allowlists whose line-hint halves were audited directly.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@codecov

codecov Bot commented Sep 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner, landing sweep 02:41: this PR is red on clang-tidy-diff (51 pass, 1 fail, 1 pending). Its run (35544723152) is still in progress, so the job log is not retrievable yet and I am not guessing at the cause from the diff. Queued for the next sweep, which reads the completed log first. Note that #647 landed in the meantime (master is now f7c231df) and it changed this very leg — it now builds two *_autogen targets so the two AUTOMOC self-including sources parse. That is a gate change under .github/workflows/, so this PR will need a rebase before it can be judged regardless of what the log says.

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner: diagnosed — the red is not in this PR's code

The run has been stalled for over an hour (Linux / all optional features (clang) pending at 0 elapsed, run last updated 23:49Z), so the job log is unreachable. I read the clang-tidy-report artifact instead, which the step uploads with if: always().

Every finding is a clang-diagnostic-error from a TU that cannot parse in this job's configure, in a file this PR edits only in comments.

examples/bookmarks/gui_wasm/main_wasm.cpp:58:10: error: 'QGuiApplication' file not found
examples/common/wasm_spike/main_wasm.cpp:47:29:  error: expected ')'
examples/pastebin/gui_wasm/main_wasm.cpp:50:10:  error: 'QGuiApplication' file not found
examples/polls/gui_wasm/main_wasm.cpp:178:10:    error: 'emscripten/emscripten.h' file not found
include/morph/core/bridge.hpp:1610:43:           error: no matching member function for call to 'into'
include/morph/core/bridge.hpp:2667:37:           error: no matching member function for call to 'executeVia'

The two bridge.hpp errors are not bridge.hpp's. The notes chain them to their origin:

bridge.hpp:1610 … note: in instantiation of 'Bridge::executeVia<ClientOnlyFacadeModel, …>'
                 note: requested here → tests/compile_checks/client_only_facade_no_model_header.cpp:53
                 note: candidate template ignored: substitution failure [with Model = ClientOnlyFacadeModel]

That TU deliberately omits model.hpp — it exists to prove the client-only facade compiles without it — so ClientOnlyFacadeModel is incomplete by design and the failure cascades into templates attributed to the header. Not a defect in this branch's changes.

Why it appeared now: this PR edits all six of those files, and every edit is prose. For example:

-// Bridge::registerHandler(binding) (registerModelWithContext has no async
-// path for LocalBackend -- see IBackend::registerModelAsync's doc comment),
+// Bridge::registerHandler(binding) (LocalBackend does not override bindModel,
+// so IBackend's default runs registerModelWithContext inline -- see
+// IBackend::bindModel's doc comment), so the factory must succeed

A changed comment line is a changed line, so clang-tidy-diff analysed six TUs that nothing had ever analysed — four needing an Emscripten/Qt-WASM toolchain this job does not configure, and one designed to be incomplete.

This is the third instance of one structural problem, and it is worth naming as such rather than fixing three times: #624 (the two AUTOMOC self-including sources), #650 (tests/lint/ fixtures green only by accident of content), and now this. The rule nobody has written down is editing a line in a file the clang-tidy configure cannot build turns that file red, and there is no signal until CI says so — and the #570 half of this PR is a pure documentation change, which is the least likely way anyone expects to trip a compiler gate.

Also one real finding, unrelated to the above and not to be lost in it: 1 × bugprone-exception-escape. That one is worth reading properly rather than suppressing with the rest.

Dispatching a lane whose whole batch is this PR, with this artifact. It also needs a rebase independently: #647 landed and changed this leg, which is a gate delta.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

Yaraslaut and others added 5 commits September 21, 2026 02:50
…ed async twins (fixes #570)

The ticket is scoped to "move the example GUIs and the WASM spike onto the
structural registration surface". They were already on it: nothing under
`examples/` ever called `registerModelAsync`, `registerModelSharedAsync`,
`attachModelAsync` or `assignPrimaryAsync` — application code reaches a
backend only through `Bridge`, and morph#568 moved `Bridge`'s four dispatch
sites. Verified rather than assumed: every one of the 25 hits under
`examples/` on `c55ea5b7` was a `//` comment, a `///` doc comment, a CMake
comment or a line of Markdown.

So what this commit moves is the prose, which named verbs that morph#571
deletes and that nothing had implemented since morph#568.

- The GUI clients and `AppContext` now name `QtWebSocketBackend::bindModel()`
  as what queues a private bind issued before the socket connects. The
  behaviour they describe is unchanged and still true: `bindModel` queues a
  request with an empty `primary` and sends it on the next `connected`, and
  rejects a keyed one with `"disconnected"` (`src/qt/qt_websocket_backend.cpp`,
  the `!_connected` arms of `bindModel`).
- `AppContext`'s readiness contract gains the half it was missing: with
  `asyncRegistrationEnabled` the backend answers
  `BindWait::kCallerMustNotBlock`, so `registerHandler` hands back an
  *unbound* handler and a caller must gate on `whenBound()`/`isBound()`.
  That was true before this commit too; the doc comment only described the
  queue.
- `examples/polls/README.md`'s struck `assignPrimaryAsync` claim keeps its
  correction but states it against `IBackend::promoteModel`, which no backend
  can decline — a stronger version of the same rebuttal.
- `LADDER.md`, `TESTING.md` and `polls/README.md` keep their "shipped"
  history: each says what shipped then and that morph#567–morph#571 replaced
  it, rather than deleting the record.

`examples/common/testkit/test_wasm_registration_path_native.cpp` still pins
the single-threaded registration path through `Bridge` — only its comment
changed, and its citation of `tests/qt/test_qt_websocket.cpp` now names that
file's current test ("bindModel called before the socket connects queues and
retries once connected fires").

Verification: `cmake --build build` green with
`-DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all -DMORPH_BUILD_QT=ON
-DMORPH_BUILD_NET=ON`; `examples/common/ladder_common_tests` 587 assertions in
153 cases, all passing. **Not verified: anything WASM.** No Emscripten
toolchain was available, so neither `morph_ladder_wasm_spike` nor any
`*_gui_wasm` target was configured, let alone built or run — those targets
exist only in an Emscripten configure. The acceptance criterion "the WASM
ladder clients build, and the WASM demo deploys" is therefore unmet by
local measurement and rests entirely on the `wasm-ladder` and `wasm-demo` CI
legs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…ding contract (fixes #571)

`registerModelAsync`, `registerModelSharedAsync`, `attachModelAsync` and
`assignPrimaryAsync` are gone from `IBackend` and from
`SynchronousBackendAdapter`, and `Bridge`'s four "offer the twin first, fall
back to `bindModel`" branches are now one unconditional dispatch each.

    $ grep -rn 'registerModelAsync\|registerModelSharedAsync\|attachModelAsync\|assignPrimaryAsync' include src tests examples
    $ echo $?
    1

**`IBackend`'s virtual count, and #522's prediction.** Measured on
`origin/master` (`c55ea5b7`) and on this commit, excluding the destructor:

| | virtuals | pure | defaulted |
|---|---|---|---|
| before | 21 | 5 | 16 |
| after  | 17 | 5 | 12 |

#522 says "18 virtuals with 14 defaulted" and predicts the interface "roughly
halves". **The prediction was wrong on both counts.** The count it quotes was
already stale when it was written (there were 21, not 18, once
`bindModel`/`promoteModel`/`bindWaitPolicy` landed), and removing four of
twenty-one is a 19% reduction, not a halving. What the set actually delivered
is the thing #522 is about — there is now exactly one acquire surface and one
promote surface, and no call site carries a second path — but the arithmetic
in the ticket should not be quoted as if it came true.

**What the twins carried that the surface does not.** One thing, named rather
than left to be found. A `bool` twin handed `Bridge` two raw `std::function`s,
so a backend that violated "exactly one callback" by firing twice reached
`detail::parkIfInFrame`'s own double-claim guard. A `Completion` cannot be
settled twice — `CompletionState` drops the second settle before any `Bridge`
code sees it — so `DoubleFiringBackend` in `tests/test_async_registration.cpp`
now pins the observable contract ("exactly one `onDone`") while that guard is
no longer reachable *from a backend*. The guard is kept because
`parkIfInFrame` is also called from the dispatching frame. This is recorded in
the double's own comment and in `docs/spec/core/backend.md`.

**The prose threading contract is relocated, not deleted.** The `@note` block
shared by the four twins asked every backend author to deliver
`onRegistered`/`onError` from a thread on which `~Bridge` could not run
concurrently — morph#486's use-after-free. That reasoning is still true and now
lives in `docs/spec/core/backend.md`, "What was wrong with the old shape"
(what the contract was, and why `registerHandlerImpl` was the one site that
did not depend on it) and "How the threading contract becomes structural"
(what replaced it), plus
`docs/spec/concurrency_and_lifetimes.md`'s bind/promote-continuation bullet.

Per the runner decision recorded on #571, the claim is stated narrowly in all
three places: the guarantee is structural **for backends**, and `Bridge`'s four
dispatch sites still name `exec::detail::inlineExecutor()`, which reproduces
the old delivery thread exactly. **The morph#486 window is unchanged, not
closed.** Closing it means giving `Bridge` an executor of its own, which is
morph#588 and is not in this commit.

**The ticket's "Also in scope" list was stale, and is not acted on.** It asks
for `LocalBackend`, `SimulatedRemoteBackend` and "the 11 test doubles" across
`tests/test_switch_backend.cpp`, `tests/test_bridge_lifetime.cpp` and
`tests/test_client_execute_deadline.cpp` to be migrated, "candidates for
`SynchronousBackendAdapter` rather than hand-editing". Checked: none of those
three files contains a single reference to any of the four verbs, and neither
does `LocalBackend` or `SimulatedRemoteBackend`. They need no migration —
`IBackend`'s default `bindModel` runs `bindModelBlocking`, which dispatches to
exactly the synchronous verb each request shape names, so they behave
identically through the surface. Wrapping them in `SynchronousBackendAdapter`
would be a *regression*: the adapter answers `kCallerMustNotBlock`, which would
turn every `registerHandler` against a `LocalBackend` into an unbound handler.
The only doubles that did override the twins are the ones in
`tests/test_async_registration.cpp`, and they are migrated here.

**Migrated doubles** (`tests/test_async_registration.cpp`): `AsyncRegisterBackend`
now overrides `bindModel` (one verb for all three acquire shapes, deferred into
the same `completeNext()`/`failNext()` queue) and answers
`BindWait::kCallerMustNotBlock`, which is what reproduces "dispatch and return
without waiting" — the observable behaviour the `true` return used to produce.
`InlineCompletingBackend`, `ThrowingDispatchBackend`, `DoubleFiringBackend` and
`AsyncBackendShim` follow; `AsyncAssignPrimaryBackend` and
`SelfFiringAssignPrimaryBackend` move to `promoteModel`.
`ThrowingDispatchBackend` tells its two arms apart by `request.primary` rather
than `request.current`: `attachHandlerAsync`'s *first* attach carries a zero
`current`, so `current` would have put both tests on the same arm — a real
detail of the surface the twins hid, found by the test failing.

`tests/test_backend_registration_surface.cpp`'s `RecordingBackend` drops the
four twins and the four forwarding assertions with them; the adapter's
forwarding test still pins every synchronous verb.

**What this does and does not establish.** The suite passes, and for a deletion
that proves only that nothing referenced the deleted thing. Beyond compilation:
every test that previously drove the twin branch now drives the `bindModel`
branch with the same double and asserts the same outcomes, including the inline
settle, the inline failure, the synchronously-throwing dispatch, the
double-settle, the three staleness guards and the two ~Bridge/~binding
teardown races — so the migration is checked by tests that were written against
the twins' behaviour, not by new ones written against the replacement. The one
case where that is *not* true is `parkIfInFrame`'s double-claim guard, named
above. No behavioural claim beyond that is made.

Verification, on this commit, Linux/GCC 16.2.1, Debug:

    tests/morph_tests                       22931 assertions, 1556 cases  (1 failed as expected)
    tests/qt/morph_qt_tests                   578 assertions,   79 cases
    tests/net/morph_net_tests                1112 assertions,  191 cases
    tests/net_qt_interop/..._tests              9 assertions,    2 cases
    examples/common/ladder_common_tests       587 assertions,  153 cases

    $ bash scripts/check_spec_citations.sh
    Prose lint OK: every pinned fact is still cited; no banned terminology found; ...
    $ bash scripts/test_check_spec_citations.sh
    scripts/check_spec_citations.sh detects every section-citation and forms-vocabulary drift it claims to.

Not verified: any WASM configuration (no Emscripten toolchain), clang-tidy, and
any sanitizer build.

`docs/spec/core/backend.md`'s "Asynchronous registration" section is replaced by
"Why registration needs a non-blocking path", which keeps the live content (the
nested-`QEventLoop`/WASM rationale, the `asyncRegistrationEnabled` gate, the
pre-connect queue, and what an unbound handler means) and hands the history to
"What was wrong with the old shape". Every citation of the old heading is
repointed — `locality.md`, `bridge.md`, `shared_instances.md`,
`include/morph/core/bridge.hpp` (three sites, to `bridge.md`'s "Registration
readiness" or `backend.md`'s "Waiting for a bind"), and three `examples/` files.
`scripts/test_check_spec_citations.sh`'s rename fixture is repointed to the new
heading, and its self-test still catches all sixteen drifts it claims to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…moved

Mechanical follow-up to the two commits above, kept separate so that neither
ticket's diff contains a number nobody read the code for. No behaviour change.

`scripts/mutation_survivors.json` — three `backend.hpp` citations. The gate
reported one moved line and two "appears 2 times … not decidable", exactly as
those two entries' own `reason` text predicted it would:

    include/morph/core/backend.hpp:1412 has moved to line 1138. ...
    include/morph/core/backend.hpp:1305 is allowlisted by a source line that appears 2 times (lines [1031, 1050]), and none of them is 1305, so which one is meant is not decidable. ...
    include/morph/core/backend.hpp:1469 is allowlisted by a source line that appears 2 times (lines [1195, 1240]), and none of them is 1469, so which one is meant is not decidable. ...

Each ambiguity was resolved by reading both candidates, not by taking the
first:

- `registerCount` → **1031**, inside `LocalBackend::registerModel`. The other
  candidate, 1050, is the `registerModelShared` arm, which the entry's own
  reason names as the one it is *not*.
- `executeInFlight` → **1195**, the statement immediately after
  `inFlightCounter->fetch_add(...)`. The other candidate, 1240, follows
  `fetch_sub` inside the posted task; the entry's reason says "the increment
  side of an execute".
- `aware.reserve(...)` → 1138, unambiguous.

`scripts/branch_partial_allowlist.json` — three entries, same shape:
`backend.hpp:1414 → 1140`, `bridge.hpp:1551 → 1540`, and
`bridge.hpp:1673 → 1662`. The last is the ambiguous one: three textually
identical `if (deadlineHandle && schedulerRef) {` guards exist, now at 1662,
1686 and 1775. 1662 is the one in `executeVia`'s `catch (...)` block, which is
what that entry's reason describes ("specifically the exception-path use of
the guard"); the other two are the `.then()`/`.onError()` continuations the
same reason explicitly excludes, and its parenthetical naming their old lines
is updated with them.

One further hit was **not** edited: `mutation_survivors.json`'s
`classification_2026_09_09` sample citing `bridge.hpp:584` and
`started = backend->attachModelAsync(...)`. That is a dated record of what a
sampling run measured on that revision, not a description of the current tree,
and rewriting it to match today's code would destroy the measurement. A `note`
saying so is added beside the verdict instead. It carries no verbatim `source`
and so is not audited by the gate either way.

Verification:

    $ python3 scripts/check_mutation_survivors.py
    ok: 15 structured citation(s) in scripts/mutation_survivors.json resolve to the line they name.
    note: 20 further citation(s) in this file are free text inside prose
          strings, which carry no verbatim `source` and so are NOT audited here (morph#613).

`scripts/check_branch_coverage.py` and `scripts/check_error_path_coverage.py`
both need `build/clang-coverage/coverage.lcov`, which needs a full
`scripts/coverage.sh` run and was not produced here. Their line-hint half was
audited directly instead, by calling
`check_branch_coverage.resolve_allowlist_source_line` over every entry of both
allowlists: 0 failures for each. **Their coverage half — whether each
allowlisted arm is still partial — is therefore not verified locally and rests
on the CI coverage leg.**

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…xception out of its destructor

`~SelfFiringAssignPrimaryBackend` resolves its one still-pending
`ModelCompletion` as a last act of teardown, which is the only way to reach
`assignHandlerPrimary`'s `!pinned` arm. Settling is not a non-throwing
operation, and a destructor is implicitly `noexcept`, so an escape there is
`std::terminate` with no attribution rather than a failed assertion.

Reproduced, not inferred -- clang-tidy 22.1.8 (CI's pinned major) driven by
`clang-tidy-diff.py` with this job's own configure and flags, on the branch
before this commit:

    tests/test_async_registration.cpp:582:5: error: an exception may be thrown
    in function '~SelfFiringAssignPrimaryBackend' which should not throw
    exceptions [bugprone-exception-escape,-warnings-as-errors]
      note: frame #0: unhandled exception of type 'bad_weak_ptr' may be thrown
            in function '__throw_bad_weak_ptr' here
      note: frame #5: function 'setValue' calls function 'shared_from_this'
            here            include/morph/core/completion.hpp:108
      note: frame #6: function 'resolve' calls function 'setValue' here
      note: frame #7: function '~SelfFiringAssignPrimaryBackend' calls
            function 'resolve' here

The named `bad_weak_ptr` is a static over-approximation: the `Promise` holds a
`shared_ptr` to the state, so `shared_from_this()` cannot fail here. The
destructor's obligation is not, because `setValue` also runs the continuation
`Bridge::assignHandlerPrimary` attached, inline, on this thread -- that is
ordinary caller code and nothing makes it non-throwing.

So the body is wrapped in `try`/`catch (...)`, and the catch *records* into an
`std::exception_ptr` the test owns and checks after the backend is gone. A
`NOLINT` would have kept the terminate; a bare `catch (...) {}` would have
turned it into silence. The new `CHECK` is what keeps the arm from being a
swallow.

Measured after: the same clang-tidy run reports nothing in this file's changed
lines, and re-introducing the unguarded destructor makes it report the finding
again at the moved line (609). `morph_tests`: 22932 assertions in 1556 cases,
1 failed as expected -- the branch's baseline plus this commit's one `CHECK`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…er builds

Third instance of one structural problem, after #624 (a build-time generated
header) and #650 (tests/lint/ text fixtures): `clang-tidy-diff.py` analyses
every changed C/C++ line, a changed *comment* line is a changed line, and
clang tooling does not skip a file it has no compile command for -- it
interpolates a neighbouring entry's command. What comes back is a
`clang-diagnostic-error` about this job's configure, not a finding about the
diff, and `WarningsAsErrors: "*"` makes it a failed job.

Reproduced on this branch with the `clang-tidy` job's own configure flags and
clang-tidy 22.1.8: **21 `clang-diagnostic-error`s, all from five changed
sources absent from `compile_commands.json`**, none of them naming a changed
line's defect:

    examples/bookmarks/gui_wasm/main_wasm.cpp:58:10: error: 'QGuiApplication' file not found
    examples/common/wasm_spike/main_wasm.cpp:47:29:  error: expected ')'
    examples/pastebin/gui_wasm/main_wasm.cpp:50:10:  error: 'QGuiApplication' file not found
    examples/polls/gui_wasm/main_wasm.cpp:178:10:    error: 'emscripten/emscripten.h' file not found
    include/morph/core/bridge.hpp:1610:43:           error: no matching member function for call to 'into'
    include/morph/core/bridge.hpp:2667:37:           error: no matching member function for call to 'executeVia'
    (+ 15 more in registry.hpp/model.hpp, all attributed upward from the same TU)

The four WASM mains need an Emscripten/Qt-WASM toolchain this job does not
configure. The `bridge.hpp`/`registry.hpp`/`model.hpp` errors are not those
headers': the note chain resolves every one to
`tests/compile_checks/client_only_facade_no_model_header.cpp`, which is built
by a configure-time `try_run()` with `-DMORPH_CLIENT_ONLY` and *deliberately*
omits `model.hpp`, so its model type is incomplete by design.

Reverting the comment edits is not a general answer, and here was not even a
local one. Measured: the four WASM comments cite a `docs/spec/core/backend.md`
section that the same branch renames, so leaving them at their old text turns
the spec-citation gate red instead --

    ::error file=examples/bookmarks/gui_wasm/main_wasm.cpp,line=88::dangling section citation: docs/spec/core/backend.md has no section "Asynchronous registration"
    ::error file=examples/pastebin/gui_wasm/main_wasm.cpp,line=78::dangling section citation: ...
    ::error file=examples/polls/gui_wasm/main_wasm.cpp,line=254::dangling section citation: ...  (x2)

So the diff is filtered before `clang-tidy-diff.py` sees it. A changed
*source* with no entry in `compile_commands.json` is dropped and named in the
log with a `::warning::`; a changed *header* is never dropped, because a
header is never a translation unit and discarding headers is what
`-only-check-in-db` does -- #479's own defect one directory over. Sections are
kept or dropped whole, never hunk by hunk: `clang-tidy-diff.py` attributes
`@@` lines to the last `+++` it saw.

The filter is only as honest as the database it consults, so it refuses to run
unless that database is still the wide one the Configure step builds: at least
600 in-workspace sources and at least 200 under `examples/`. Counting only
paths that resolve *inside* the workspace means a resolution mismatch trips
the floor too, rather than silently skipping everything. Measured here: 703
entries naming 695 distinct in-workspace sources, 270 under `examples/`; CI
measured 690/276 at #481's revision. The regression the floors exist to catch
-- `MORPH_BUILD_LADDER` back to its OFF default -- takes `examples/` to 16.

Verified, not asserted:

  * with the filter, `clang-tidy-diff.py` over this branch's diff exits 0 and
    reports nothing; 26 of 31 changed file sections analysed, 5 skipped and
    each named;
  * the filtered gate can still fail: re-introducing the unguarded
    `~SelfFiringAssignPrimaryBackend` makes it exit 1 with
    `bugprone-exception-escape` on the changed line;
  * the Python embedded in the workflow was extracted back out of the parsed
    YAML and produces a byte-identical filtered diff to the version tested
    standalone;
  * `check_workflow_job_banners.py`, `check_ci_clang_pin.sh` and
    `check_workflow_option_coverage.py` all pass on the edited file.

Not verified: no Emscripten toolchain was available, so nothing was built for
the four WASM mains -- the claim is only that this job cannot analyse them,
which is what their absence from `compile_commands.json` shows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner verification — the gate change, checked by driving it, not by reading it

This PR now changes clang-tidy-diff's scope, and AGENTS.md lists "a filter over an invariant body" among the failure modes this repository has actually hit. So I extracted the filter from the parsed workflow (95 lines, via yaml.safe_load → the step's run → the heredoc) and drove it against synthetic databases.

It keeps what it must keep. A diff touching three files, against a database clearing both floors:

::warning file=examples/unbuilt_wasm.cpp::not analysed by clang-tidy-diff: this configure
    builds no translation unit for it …
ok: compile database names 660 in-workspace source(s) (240 under examples/);
    2 of 3 changed file section(s) analysed, 1 source(s) skipped as unbuilt here
exit=0

sections kept:
    +++ b/src/f1.cpp                        (source, in the database)
    +++ b/include/morph/only_a_header.hpp   (header, NOT in the database — kept)

The header is kept although nothing in the database names it, which is the property that separates this from -only-check-in-db and from #479's defect. A dropped source is named in a ::warning file=, so a skip is visible in the run rather than silent.

The floor trips in both directions, and the second floor is not redundant:

420 src + 16 examples   (the MORPH_BUILD_LADDER=OFF shape)  -> ::error:: … exit=1
660 src + 150 examples  (wide overall, thin in examples/)    -> ::error:: … exit=1

That second case is the one I most wanted to see fail: a configure that still looks big while examples/ has collapsed would otherwise let the filter swallow the ladder without anyone noticing. It does not.

Accepting the forced choice on its evidence. The cheap option — revert the comment edits — was not available, and the lane established that rather than asserting it: the four WASM comments cite a docs/spec/core/backend.md section this branch renames, and reverting them produces four dangling section citation errors from check_spec_citations.sh. So any edit at all puts those files in the diff, and the gate had to change.

The residual, stated plainly because a filter's cost is what it stops seeing: a source that drops out of the database without breaching either floor is skipped with only a warning. #651 quantifies that at 60 of 470 tracked C/C++ sources, and usefully splits it by cause — 11 only because this job does not pass -DMORPH_BUILD_BANK_GUI=ON (which linux-all-features already does, so those are recoverable), 36 not translation units by design, 13 WASM with no lint job anywhere. The 11 are the interesting ones and I would rather see them fixed than filtered, but that is #651's business, not this PR's.

On the real finding: keeping bugprone-exception-escape out of the group-1 fix was right, and the fix is better than a NOLINT — the catch (...) records into an exception_ptr the test CHECKs after destruction, so an escape becomes a named test failure instead of std::terminate. The lane also probed the three compiled production sources for the same destructor shape and found none, and filed nothing — the correct outcome for a clean probe.

Not merged: CI incomplete at hand-off. Next sweep counts the checks — and the check to watch is clang-tidy-diff itself, whose own behaviour this PR changes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut
Yaraslaut merged commit 82c3a02 into master Sep 21, 2026
53 checks passed
Yaraslaut added a commit that referenced this pull request Sep 21, 2026
…uding TUs, and drop a deleted workflow from a docstring (fixes #646, fixes #643) (#653)

* testkit/qt: clear the 84 clang-tidy findings the two AUTOMOC self-including TUs now report (fixes #646)

#647 made the clang-tidy job build `ladder_common_tests_autogen` and
`morph_forms_qml_tests_autogen`, so `examples/common/testkit/test_qml_surface.cpp`
and `src/qt/forms/tests/tst_main.cpp` parse for the first time and are
analysed. Nothing was broken -- clang-tidy-diff reports only changed lines --
but the first PR to touch one of those lines would have inherited findings
that were not its own.

Re-measured on 0067b5b with the clang-tidy job's own configure flags and
clang-tidy 22.1.8 (CI's pinned major), both AUTOMOC targets built first:
81 findings in test_qml_surface.cpp (73 misc-const-correctness, 3
readability-convert-member-functions-to-static, 2
readability-inconsistent-declaration-parameter-name, 2
readability-identifier-length, 1 bugprone-easily-swappable-parameters) and 3
in tst_main.cpp. That reproduces the #647 lane's figure at c55ea5b exactly.

77 of the 84 are fixed rather than suppressed:

  * 73 `misc-const-correctness` -- local `QTemporaryDir` and fixture-bridge
    declarations that are never mutated. Applied with clang-tidy --fix, then
    rewritten to the west-const spelling the rest of the file uses.
  * 2 `readability-identifier-length` -- `id` -> `rowId`, `ok` -> `okay`.
    Safe: QmlSurfaceAudit reads `QMetaMethod::name()` and `parameterCount()`
    and never a parameter name, and the QML fixture text is unchanged.
  * 1 `misc-use-internal-linkage` -- `MorphFormsQmlTestSetup` moves into an
    anonymous namespace; QUICK_TEST_MAIN_WITH_SETUP expands in the same TU.
  * 1 `readability-redundant-access-specifiers` -- the explicitly defaulted
    default constructor and its `public:` are removed, which also removes the
    redundancy, since Q_OBJECT ends in `private:`.

The remaining 7 get individually reasoned NOLINTNEXTLINEs, reason above the
directive (#631/#627's rule) -- no NOLINT sweep and no new `.clang-tidy`
entry, which is what #632 was about:

  * 4 `readability-convert-member-functions-to-static` on Q_PROPERTY readers,
    a Q_INVOKABLE and a Qt Quick Test setup slot. The reason is shape, not
    legality: the static form was measured to compile and moc registers the
    same property, but no bridge these fixtures stand in for has a static
    property reader, and a Qt slot is a member function by definition.
  * 2 `readability-inconsistent-declaration-parameter-name` on the two
    signals. moc's generated definitions name the parameters `_t1`/`_t2`, so
    no edit to the declarations can remove the mismatch; the finding reaches
    these files only because the classes are declared in a .cpp.
  * 1 `bugprone-easily-swappable-parameters` on the file-local `writeQml`
    helper's two adjacent `const QString&`.

Verified, not asserted:

  * Both files are in this configure's compile_commands.json (703 entries,
    695 in-workspace, 270 under examples/ -- above #649's 600/200 floors).
  * clang-tidy exits 0 on both files afterwards, and with every
    NOLINTNEXTLINE line stripped it exits 1 reporting exactly the 6 + 1
    suppressed findings again. Each directive is load-bearing and the TUs are
    really analysed, rather than clean because nothing looked at them.
  * `ladder_common_tests "[qml-surface]"`: 182 assertions in 36 test cases,
    all passing.
  * `morph_forms_qml_tests -input src/qt/forms/tests`: 292 passed, 0 failed,
    the two corpus-reading suites among them -- which is what proves the
    setup slot still runs after the anonymous-namespace move.
  * clang-format 22.1.8 clean; check_nolint_directives.sh, check_bidi_controls.py,
    check_tidy_suppression_scope.sh, check_automoc_includes.sh,
    check_catch_test_names.sh all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

* ci: drop the deleted suppression-guard.yml from the banner gate's docstring (fixes #643)

#635 deleted .github/workflows/suppression-guard.yml; the checker's "## Scope"
paragraph still named it as one of the single-job workflows the gate skips. It
was the last reference to that file in the tree:

    $ git grep -n "suppression-guard" -- .
    scripts/check_workflow_job_banners.py:50:suppression-guard.yml and the two wasm workflows are single-job files that have

Gate behaviour was never affected and is not affected now -- the skip is
derived per file, not read from that list. Measured on 0067b5b, over the five
workflows with zero banners:

    ok: .github/workflows/docs.yml: no section banners, not in the banner style
    ok: .github/workflows/mutation.yml: no section banners, not in the banner style
    ok: .github/workflows/spec-sync.yml: no section banners, not in the banner style
    ok: .github/workflows/wasm-demo.yml: no section banners, not in the banner style
    ok: .github/workflows/wasm-ladder.yml: no section banners, not in the banner style

    ok: all 24 section banner(s) introduce the job they describe

So the name is deleted rather than swapped for another: with it gone the
sentence enumerates exactly the five files a run reports as skipped, and there
is no sixth current example to put in its place.

A second paragraph says so explicitly -- the list is an illustration with a
shelf life, nothing reads it, and the run's own output is the current list --
so the next workflow deletion dates one sentence instead of producing a third
round of this.

Verified: `python3 scripts/check_workflow_job_banners.py .` and
`bash scripts/test_check_workflow_job_banners.sh` both pass; the five skipped
files and their single-job counts were enumerated from the tree with the
checker's own BANNER_RE/JOB_KEY_RE rather than read off the docstring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

---------

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

Labels

None yet

Projects

None yet

1 participant