Skip to content

ci/testkit: three gates that did not cover what they appeared to (fixes #651, fixes #652, fixes #655) - #657

Merged
Yaraslaut merged 3 commits into
masterfrom
laneCI3-batch-651-652-655
Sep 21, 2026
Merged

Yaraslaut merged 3 commits into
masterfrom
laneCI3-batch-651-652-655

Conversation

@Yaraslaut

@Yaraslaut Yaraslaut commented Sep 21, 2026

Copy link
Copy Markdown
Member

Three tickets about gates that do not cover what they appear to. One commit
each, all three landed, nothing rejected.

  • fixes #651 — the clang-tidy-diff job's configure was missing one flag
  • fixes #652 — a suppression file that misdescribed its own reach
  • fixes #655 — a gate that answered OK to an empty path list

Filed rather than folded: #656, the 97 clang-tidy findings that #651's
flag brings into reach, and #659, the missing gate behind the AUTOMOC
regression described under #652 below.

Revision note. Rebased onto f24e225a (master moved twice: #653 and
#654). #652's commit was amended to fix an AUTOMOC regression it introduced,
which had turned six CI legs red on undefined reference to vtable for morph::ladder::testkit::FaultProxy; the section on #652 covers the cause, the
fix, why it was amended rather than stacked, and a verification claim in the
previous revision that turned out to be false. #651 and #655 are byte-identical.


#651-DMORPH_BUILD_BANK_GUI=ON for the clang-tidy job

MORPH_BUILD_BANK_GUI defaults OFF and the clang-tidy job did not pass it,
so eleven tracked sources had no compile command and were dropped from the
diff by #649's filter — visibly, with a ::warning file=, but dropped.

Measured with a cold build directory each time, this job's own flag set, clang
22.1.8, Qt 6:

entries distinct in-workspace under examples/ tracked sources absent configure
without BANK_GUI 703 695 270 63 of 473 63.7s
with BANK_GUI 737 727 281 52 of 473 64.4s

The 11-file delta is exactly the nine under examples/bank/gui/ and the two
under examples/bank/tests/gui/. That measurement is the mutation test:
it is the same command with and without the flag, so the change cannot be
reporting success while doing nothing. Configure cost is under a second —
nothing here ever builds those targets.

#649's floors move away from their limit (600 in-workspace → 727, 200
under examples/ → 281), and the two numbers in that step's comment are
updated to the measured ones.

No new installs. linux-all-features already builds this option on
ubuntu-24.04 with the identical jurplel/install-qt-action@v4 modules: qtwebsockets and the identical apt set, which is why this is a flag
and not a job. Locally, bank_gui_lib and bank_gui_tests both compile under
this configure (verified while working #652, which touches a source
bank_gui_tests compiles).

The trap, measured before landing. Whole-file, with this step's own
-extra-arg pair: 97 findings inside the eleven sources themselves,
across ten of them (BankController.cpp is clean, BankClient.cpp has none
of its own). 44 cppcoreguidelines-pro-bounds-avoid-unchecked-container-access,
23 performance-unnecessary-value-param, 9 misc-const-correctness, 8
readability-identifier-length, 13 others. Findings landing in
include/morph/** headers are excluded from that count — those headers are
already analysed from 700-odd other TUs and are not new exposure.

Filed, not cleared, and deliberately: #656. Two reasons. AGENTS.md — the
flag being absent and the code under it not being clean are two facts. And
clearing 97 findings across ten Qt GUI sources is a change that has to be
built to be trustworthy, while this job deliberately never builds; it
belongs with a leg that compiles bank_gui. #646 is the precedent, and
clang-tidy-diff reports only on changed lines, so nothing goes red until
someone edits one of these files on a line that carries a finding.

Slices (2) and (3) are left, as instructed, and the commit says so. The 36
sources under tests/lint/ and tests/compile_checks/ are not translation
units by design — a recording job (#650), not a fixing one. The 13 WASM
sources are lint-analysed by no workflow at all — a new job, not a flag,
and uncosted.

#652 — the testkit library's two TUs move out of the Catch2 suppression

examples/common/testkit/.clang-tidy subtracts bugprone-chained-comparison
and argues for it as Catch2 idiom: "an entry here is a check whose finding is
test idiom — a property of Catch2 — rather than a defect"
, "a hand-written
a < b < c in the code under test is still reported"
. That directory also
held morph_ladder_testkit's two translation units, neither containing a
REQUIRE.

Which of the three fixes, and what the others cost. Narrowing, not
rewording: fault_proxy.cpp and qml_surface.cpp move to
examples/common/testkit_src/, outside the file's reach, so the claim holds
by construction. Their headers stay in testkit/ — every consumer includes
them as <testkit/...>, which is also what resolves the moved TU's own
include.

Behavioural verification, with the repository's real .clang-tidy files
rather than a --checks override.
A return aaa < bbb < ccc; probe
appended to the moved TU:

examples/common/testkit_src/qml_surface.cpp:691:95: error: chained comparison 'v0 < v1 < v2' may generate unintended results, use parentheses to specify order of evaluation or a logical operator to separate comparison expressions [bugprone-chained-comparison,-warnings-as-errors]

and the anti-vacuity control, the same probe in a TU still inside the
suppressed directory, which must report the clang diagnostic and not the
tidy check:

examples/common/testkit/test_convergence.cpp:68:82: error: chained comparison 'X < Y < Z' does not behave the same as a mathematical expression [clang-diagnostic-parentheses]

Both probes were removed; neither is on this branch. What this buys today is
nothing
: before the move, --checks=-*,bugprone-chained-comparison over
both library TUs reported no findings. What it buys is that the file's
description of its own reach is now checkable.

And checked, because the next library source would arrive the same silent
way. scripts/check_rung_filters.sh's check 5 gains a third condition: every
.cpp a Catch2 suppression reaches, recursively, must itself be a Catch2 TU —
a TEST_CASE/SCENARIO, a catch2/ include, or CATCH_CONFIG_* for a
Catch2 main such as testkit_main.cpp. Across the nine suppressed directories
that is 154 sources and, before this change, exactly two exceptions: the two
this commit moves
. Read from the source rather than from a build file,
because the jobs that run this gate have none.

Mutation-tested: restoring qml_surface.cpp to testkit/ takes the gate from
All 58 rung-filter checks passed to 1 of 58 rung-filter check(s) failed,
naming the file. A directory carrying the suppression with no .cpp at all
fails too — a scan that examines nothing reports "all Catch2" as loudly as one
that examined everything.

scripts/test_check_rung_filters.sh pins all three new rejections, and now
copies the real test sources into its scratch tree rather than the
.clang-tidy files alone — a synthetic corpus would go on passing after the
real tree had grown a library source in one of these directories, which is the
failure this is about. One cp -r per directory, not per file: per-file
copying cost six thousand processes and took the self-test from 50s to five
minutes.

The move broke AUTOMOC, and that is where this branch first went red.
fault_proxy.hpp carries Q_OBJECT. CMake's AUTOMOC documentation gives a
header exactly two routes onto the scan list: "all header files in the
target's sources"
, and a same-base-name header found beside each C++ source.
Moving fault_proxy.cpp out of testkit/ broke the second and nothing
supplied the first, so moc_fault_proxy.cpp stopped being generated.
Compilation is unaffected and the static library archives clean — it surfaces
only at the first link that needs the vtable, which was six legs at once
(Application ladder, Application ladder / ASan+UBSan,
Kanban / ThreadSanitizer, Linux / all optional features (clang and gcc),
Linux / clang-coverage).

The fix. testkit/fault_proxy.hpp and testkit/qml_surface.hpp are
listed as sources of morph_ladder_testkit. Header entries are not compiled;
they only join the AUTOMOC scan.

Why this and not the other two candidates.

  • Moving the headers down beside their TUs rewrites every <testkit/...>
    include in the tree, and buys the .clang-tidy argument nothing it does not
    already have: headers are not translation units, so check 5 never looked at
    them.
  • An AUTOMOC search path does not exist. Of the properties CMake 4.4 offers,
    AUTOMOC_INCLUDE_DIRECTORIES sets the -I list handed to moc rather than
    the header-to-source pairing; CMAKE_AUTOMOC_RELAXED_MODE concerns
    "foo.moc" self-includes; AUTOMOC_MACRO_NAMES, AUTOMOC_DEPEND_FILTERS
    and AUTOMOC_MOC_OPTIONS leave discovery alone. Checked against
    cmake --help-property-list and each property's own documentation, not from
    memory.
  • Listing the headers is therefore the documented mechanism rather than a
    workaround for one — and it is already what cmake/morph_add_rung.cmake
    does for every rung, with the same failure behind it. Its _lib_headers
    glob exists because a rung splits include/<rung>/app/app.hpp from
    src/app/app.cpp, and its comment names pastebin::app::App as where this
    repository last hit this exact link error.

Both headers are listed, not only the one carrying Q_OBJECT today, so the
split is moc-neutral by construction rather than by which header happens to
need moc.

Reproduced and then fixed from build directories configured from empty
(clang 22.1.8 + Ninja, -DMORPH_BUILD_QT=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all -DMORPH_BUILD_TESTS=ON -DMORPH_BUILD_NET=ON -DMORPH_BUILD_OFFLINE_SQLITE=ON), because a warm tree still holding an old
moc_fault_proxy.cpp is precisely what hides this. Without the header
entries:

examples/common/morph_ladder_testkit_autogen/mocs_compilation.cpp:
// No files found that require moc or the moc files are included

$ nm -C examples/common/libmorph_ladder_testkit.a | grep staticMetaObject
                 U morph::ladder::testkit::FaultProxy::staticMetaObject

/usr/bin/ld: examples/common/libmorph_ladder_testkit.a(fault_proxy.cpp.o): in function `morph::ladder::testkit::FaultProxy::FaultProxy(QUrl, QObject*)':
examples/common/testkit_src/fault_proxy.cpp:10: undefined reference to `vtable for morph::ladder::testkit::FaultProxy'
/usr/bin/ld: ... undefined reference to `morph::ladder::testkit::FaultProxy::staticMetaObject'
/usr/bin/ld: ... undefined reference to `typeinfo for morph::ladder::testkit::FaultProxy'
clang++: error: linker command failed with exit code 1

— the Linux / clang-coverage failure, reproduced locally. With the header
entries, from a second empty directory and the same configure:

examples/common/morph_ladder_testkit_autogen/C4OGJZPURZ/moc_fault_proxy.cpp
0000000000000038 D morph::ladder::testkit::FaultProxy::staticMetaObject
0000000000000070 D vtable for morph::ladder::testkit::FaultProxy
00000000000000e0 D typeinfo for morph::ladder::testkit::FaultProxy

and the whole tree builds: 441 ninja edges, exit 0, ladder_common_tests
and every rung's test binary linked. moc writes
#include "testkit/fault_proxy.hpp" and not an ascending path, because the
header sits under the target's examples/common include directory and
CMAKE_AUTOMOC_PATH_PREFIX resolves it against that; check_automoc_includes.sh
over the tree reports 32 generated moc sources, none ascending. A third
empty directory with -DMORPH_BUILD_BANK_EXAMPLE=ON -DMORPH_BUILD_BANK_GUI=ON
links bank_gui_tests, which compiles the moved qml_surface.cpp by path.

Check 5 is untouched by the fix and still holds. Copying both TUs back
into examples/common/testkit/ on the fixed tree:

error: examples/common/testkit/.clang-tidy subtracts bugprone-chained-comparison as *Catch2 idiom*, but 2 of the 23 .cpp it governs contain no Catch2 at all:
    examples/common/testkit/fault_proxy.cpp
    examples/common/testkit/qml_surface.cpp
1 of 58 rung-filter check(s) failed.

Unmutated, All 58 rung-filter checks passed. A header in that directory is
not a .cpp, so the scan never sees it and listing the headers as target
sources cannot weaken the condition.

Correcting the record. The earlier revision of this commit claimed
"morph_ladder_testkit and bank_gui_tests both link", measured against a
warm build directory that still carried the pre-move moc_fault_proxy.cpp.
That claim was false on a fresh configure — which is what CI does every time.
Everything above is from directories configured from empty.

Amended into the #652 commit rather than stacked on top, because the
commit as it stood did not build: every binary linking morph_ladder_testkit
failed, so leaving it in history would put a broken tree under git bisect
for anything touching examples/. The other two commits are untouched — their
patches are byte-identical across the rebase, checked with diff over
git show --format="".

Filed, not folded: #659 — nothing gates this class of defect, and this
is its second occurrence in a repository that already documents it.

scripts/coverage.sh, examples/TESTING.md and two stale prose references in
rung tests are updated. codecov.yml needs no change — its globs name
testkit/test_*.cpp, and the moved files are under examples/common either
way, so they stay in the coverage denominator.

#655check_spec_sync.sh refuses empty input unless asked for

First, whether the exposure is live — measured, not read off the YAML.
CI's own invocation is not exposed. spec-sync.yml's step runs under
set -euo pipefail, assigns the diff to a variable and pipes a printf, so a
failing git diff aborts the step. Driving that exact shape with a bad ref:

-- shape A: the workflow's own (assignment under set -e, then printf | checker)
fatal: bad object deadbeefdeadbeef0000000000000000deadbeef
step exit=128

The documented manual shape, in a shell with neither -e nor pipefail
an interactive one, or the pre-push hook the filer was using — is exposed
exactly as filed:

$ git diff --name-only <a bad ref> HEAD | bash scripts/check_spec_sync.sh
fatal: bad object deadbeefdeadbeef0000000000000000deadbeef
Spec sync OK: the change touches no files.
pipeline exit=0

and after this change:

fatal: bad object deadbeefdeadbeef0000000000000000deadbeef
::error::spec-sync: the path list on stdin is empty, and --allow-empty was not given.
pipeline exit=1

So the fix is scoped to the case that was live, and the gate is made
self-defending regardless of who calls it.

An explicit --allow-empty rather than a hard error: "this change touches no
headers" is a real and common outcome the gate must still pass, so the point
is to make silence something a caller states. An unrecognised argument exits
2 rather than being ignored — a typo'd --allow_empty that the script shrugged
off would wedge every legitimately empty invocation.

spec-sync.yml passes the flag only when it has observed the emptiness
itself
. set -euo pipefail means the git diff either produced the list or
aborted the step, so an empty $changed there is a real net-zero diff (a pull
request whose commits cancel out) and not an upstream failure. Passing it
unconditionally would hand that entitlement straight back.

Both paths pinned, plus whitespace-only input (which is what
printf '%s\n' "$changed" produces for an empty variable, i.e. the workflow's
own shape), an unrecognised argument, and --allow-empty over a real
violation, which must still be rejected. Rejecting empty is useless if
--allow-empty stops working; accepting it with the flag is useless if the
flag is the only path anyone tests.

Mutation-tested in both directions. Forcing the empty branch to always
allow: NOT caught: an empty path list with no --allow-empty, 2 self-test
checks fail. Forcing it to always reject: FALSE POSITIVE: an empty path list with --allow-empty, 1 fails. Unmutated, all 23 pass.


What was run locally, and what was not

All green on the final tree:

check_workflow_option_coverage.py, check_workflow_job_banners.py,
check_tidy_suppression_scope.sh, check_bidi_controls.py,
check_nolint_directives.sh, check_rung_filters.sh,
check_catch_test_names.sh, check_test_type_names.sh,
check_spec_citations.sh, check_ci_clang_pin.sh,
check_automoc_includes.sh, check_ctest_name_collisions.sh,
check_spec_sync.sh over this branch's real path list, and the three relevant
self-tests (test_check_spec_sync.sh, test_check_rung_filters.sh,
test_check_tidy_suppression_scope.sh).

clang-format --dry-run -Werror over all four C++ files this branch touches:
clean. The AUTOMOC fix touches no C++ — only examples/common/CMakeLists.txt
and examples/TESTING.md, neither of which the clang-format job's
git ls-files '*.hpp' '*.cpp' list covers.

Three build directories, each configured from empty, since a warm tree is
what hid the AUTOMOC break in the first place: the ladder configure before the
fix (link fails, as CI did), the same configure after it (441 ninja edges,
exit 0), and a bank configure (bank_gui_tests links).

clang-tidy-diff run end to end against this branch's own diff, through
#649's filter extracted from ci.yml: 15 of 15 changed file section(s) analysed, 0 source(s) skipped as unbuilt here, then clang-tidy-diff.py
exit 0 with no findings.

Not verified: no CI leg has been observed for this revision — the branch
was pushed and reported on immediately, so every check count quoted anywhere
is from the previous revision and is stale. Also unverified: that CI's clang 22
on ubuntu-24.04 reproduces the 97 findings in #656 exactly — local clang-tidy is 22.1.8 against Qt 6.11.2, and
the runner's Qt comes from aqtinstall at QT_VERSION; several of those
checks are sensitive to what the Qt headers declare. No test suite was run
(only compiled), and no CI leg has been observed for this branch.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner verification

The new gate condition is non-vacuous, and I ran it both ways rather than reading it. Installed this branch's check_rung_filters.sh onto the pre-move tree (4563aff3):

error: examples/common/testkit/.clang-tidy subtracts bugprone-chained-comparison as
       *Catch2 idiom*, but 2 of the 23 .cpp it governs contain no Catch2 at all:
    examples/common/testkit/fault_proxy.cpp
    examples/common/testkit/qml_surface.cpp
exit=1

and on the branch itself: exit 0. It fails in the world where the defect exists, passes in the fixed one, and names the offending files rather than just a count. It also reports a real denominator per directory (all 27 .cpp under examples/kanban/tests/ …), so a directory quietly emptying would not read as success. This is the part of the PR I would have been most worried about, and it is the part that is best evidenced.

Choosing the move over the reword is right, and the reason given for rejecting the alternative is the good kind — moving the 21 tests instead would have silently broken codecov.yml's two examples/common/testkit/test_*.cpp ignore globs, which is a Codecov-side failure nothing in this repository reports. That is #141's mode, caught by looking rather than by being bitten.

One correction to the report

The branch's safety claim is stated as resting on "linux-all-features building the same option on ubuntu-24.04 with a byte-identical install-qt-action invocation". The Qt action and version do match, but the runner is not statically ubuntu-24.04:

clang-tidy:          runs-on: ubuntu-24.04
linux-all-features:  runs-on: ${{ fromJSON(needs.probe-self-hosted.outputs.runs_on) }}

probe-self-hosted picks a self-hosted runner when one is online and falls back to '["ubuntu-24.04"]' otherwise. I checked what it actually resolved to on the last master run:

Linux / all optional features (gcc)    labels=["ubuntu-24.04"]
Linux / all optional features (clang)  labels=["ubuntu-24.04"]

So the claim holds as observed, not as guaranteed by configuration — BANK_GUI has been built on ubuntu-24.04, by the fallback path, on recent runs. That is still good evidence and it does not change the verdict; the failure mode is also benign, as the report says: a wrong guess fails loudly at Configure and dropping 3adbaee9 alone fixes it. Worth stating precisely because "another job builds it on the same image" is the kind of sentence a later reader will rely on.

A second, smaller one

The #655 conclusion — that the exposure is not live in CI — holds, but not quite for the stated reason. I found no explicit set -euo pipefail in that step; what protects it is that the diff is captured into a variable first:

changed="$(git diff --name-only "$base" HEAD)"

A failed command substitution in an assignment aborts under -e alone, with no pipeline involved and so no pipefail required. Same conclusion, different mechanism — and the branch's own if [ -z "$changed" ] now makes it explicit regardless, which is the durable part.

The --allow-empty placement is exactly right: it is passed only inside the branch where the step has itself observed the diff is empty, so silence has to be asked for by code that verified it. Mutation-tested both directions (always-allow → 2 self-test failures, always-reject → 1).

On #656

Filing the 97 rather than clearing them is the right call and matches #646's precedent: clearing findings across ten Qt GUI sources needs a leg that compiles bank_gui, which this job deliberately is not. Nothing goes red until someone edits one of those lines, and now that person finds a ticket instead of a surprise. The per-file and per-check breakdown in #656 is what makes it actionable.

Noted, not filed: test_check_rung_filters.sh at ~6,000 processes per run, cut from 5 minutes to 50s here. Sandbox-specific, but the process count is real on any runner.

Not merged: CI incomplete at hand-off. Next sweep counts the checks.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner: six legs red, one cause — the #652 move broke AUTOMOC pairing

gh pr checks 657: 34 pass, 6 failApplication ladder, Application ladder / ASan+UBSan, Kanban / ThreadSanitizer, Linux / all optional features (clang), Linux / all optional features (gcc), Linux / clang-coverage. Six legs, one root cause, and it is a link failure rather than anything six different things could produce.

From Linux / clang-coverage (the fastest to fail, 4m03s):

/usr/bin/ld: examples/common/libmorph_ladder_testkit.a(fault_proxy.cpp.o):
  in function `morph::ladder::testkit::FaultProxy::FaultProxy(QUrl, QObject*)':
  examples/common/testkit_src/fault_proxy.cpp:10: undefined reference to
      `vtable for morph::ladder::testkit::FaultProxy'
  … undefined reference to `morph::ladder::testkit::FaultProxy::staticMetaObject'
  … undefined reference to `typeinfo for morph::ladder::testkit::FaultProxy'
clang++-22: error: linker command failed with exit code 1

The same six vtable for morph::ladder::testkit errors appear in the Kanban / ThreadSanitizer leg, so the legs are not independent failures.

Diagnosis: fault_proxy.hpp carries Q_OBJECT, and the moved target is now

add_library(morph_ladder_testkit STATIC
    testkit_src/fault_proxy.cpp
    testkit_src/qml_surface.cpp
)

with the headers still in testkit/. CMake's AUTOMOC pairs a Q_OBJECT header with a source by basename in the same directory (or via the target's own header sources). Splitting fault_proxy.cpp from fault_proxy.hpp breaks that pairing, so no moc_fault_proxy.cpp is generated, so the vtable, staticMetaObject and typeinfo never exist. Nothing in the compile step notices — only the link does, which is why this got past a local build.

This is a real cost of the option chosen for #652 that neither the lane nor I priced. The PR weighed moving the library sources against moving the tests (rejected: breaks codecov.yml's ignore globs) and against rewording (rejected: leaves #632's state). AUTOMOC pairing is a third cost, and it belongs in that comparison now.

It does not invalidate the choice — the fix is small and the options are known: add the headers to the target's source list so AUTOMOC sees them, move the two headers alongside their sources, or set the appropriate AUTOMOC search path. Which one is right depends on what else includes <testkit/fault_proxy.hpp>, and the PR says every consumer already does.

Note for the fix: the branch reported "targets were compiled and linked only" as local verification. The link plainly fails in CI, so the most likely explanation is a stale build directory carrying an old moc_fault_proxy.cpp. Whatever the fix, verify it from a cold build directory — that is the specific thing that would have caught this.

Dispatching a lane whose whole batch is this PR, with this log. The other two commits (#651's flag, #655's --allow-empty) are untouched by this and should not be disturbed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

 #651)

`MORPH_BUILD_BANK_GUI` defaults OFF and the clang-tidy-diff job did not pass
it, so eleven tracked sources -- nine under examples/bank/gui/ and two under
examples/bank/tests/gui/, which exist only `if(TARGET bank_gui_lib)` -- had no
entry in compile_commands.json. Since morph#649 they are dropped from the diff
with a `::warning file=` rather than dying on a clang-diagnostic-error, so the
gate has been silently reading eleven files less than it appears to.

Measured on 4563aff with this job's own flag set, clang 22.1.8, Qt 6, a cold
build directory each time: 703 entries / 695 distinct in-workspace sources /
270 under examples/ without the flag, against 737 / 727 / 281 with it. The
delta is exactly those eleven files -- absent tracked sources go 63 to 52 of
473 -- and configure goes 63.7s to 64.4s, so the cost this option was once
argued to carry is under a second here. Both of morph#649's floors (600
in-workspace, 200 under examples/) move away from their limit.

Nothing new is installed: linux-all-features already builds this option on the
same runner image, with the same install-qt-action invocation and the same apt
set, which is what makes this a one-flag change rather than a new job.

The eleven files are not clean. Whole-file, with this step's own -extra-arg
pair: 97 findings inside the sources themselves, across ten of them. That is
morph#656, filed rather than folded in -- clang-tidy-diff reports only on
changed lines, so none of it goes red until someone edits one of these files,
and clearing 97 findings in Qt GUI code needs a leg that compiles bank_gui,
which this job deliberately is not. morph#646 is the precedent for keeping the
flag and the debt apart.

Slices (2) and (3) of morph#651 are deliberately left: the 36 sources under
tests/lint/ and tests/compile_checks/ are not translation units by design and
need recording, not fixing (morph#650), and the 13 WASM sources need a lint job
that exists in no workflow -- a new job, not a flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Yaraslaut and others added 2 commits September 21, 2026 09:00
…ression, and gate the class (fixes #652)

examples/common/testkit/.clang-tidy subtracts bugprone-chained-comparison and
argues for it as Catch2 idiom -- "an entry here is a check whose finding is
test idiom, a property of Catch2, rather than a defect", "a hand-written
`a < b < c` in the code under test is still reported". That directory also
held morph_ladder_testkit's two translation units, fault_proxy.cpp and
qml_surface.cpp, neither of which contains a REQUIRE. clang-tidy resolves
configuration from the TU's path, so the suppression covered them on the
strength of an argument that was not true of them, and the file told every
reader it could not.

Narrowed rather than reworded. The two TUs move to examples/common/testkit_src/,
one directory up and outside this .clang-tidy's reach; their headers stay in
testkit/ because every consumer includes them as <testkit/...>, which is also
what resolves qml_surface.cpp's own include after the move. The claim is now
true by construction instead of by assertion.

The other two options, and what they cost. Rewording alone is honest
immediately and leaves a Catch2-idiom suppression sitting over non-Catch2
code, which is the state morph#632 objected to. Moving the *tests* into
examples/common/testkit/tests/ is the same end state and the layout every
other example already uses, but it is 21 renames that also silently break
codecov.yml's two `examples/common/testkit/test_*.cpp` ignore globs -- a
Codecov-side change nothing in this repository would report, which is
morph#141's failure mode. This way is four files and no glob depends on it.

Measured, on this tree, clang-tidy 22.1.8 (CI pins major 22), against the
clang-tidy-diff job's own configure, with the repository's real .clang-tidy
files rather than a --checks override. A `return aaa < bbb < ccc;` probe
appended to the moved TU:

  examples/common/testkit_src/qml_surface.cpp:691:95: error: chained
  comparison 'v0 < v1 < v2' may generate unintended results, ...
  [bugprone-chained-comparison,-warnings-as-errors]

and the control, the same probe in a TU still inside the suppressed
directory, which reports the clang diagnostic and not the tidy check:

  examples/common/testkit/test_convergence.cpp:68:82: error: chained
  comparison 'X < Y < Z' does not behave the same as a mathematical
  expression [clang-diagnostic-parentheses]

Both probes were removed afterwards; neither is on this branch. Before the
move, `--checks=-*,bugprone-chained-comparison` over both library TUs reported
nothing, so this buys no finding today -- what it buys is that the file's
description of its own reach is checkable.

And checked, because the next library source would arrive the same silent way.
scripts/check_rung_filters.sh's check 5 gains a third condition: every .cpp a
Catch2 suppression reaches, recursively, must itself be a Catch2 TU -- a
TEST_CASE/SCENARIO, a catch2/ include, or CATCH_CONFIG_* for a Catch2 main.
Across the nine suppressed directories that is 154 sources and, before this
change, exactly two exceptions: the two this commit moves. A directory
carrying the suppression and holding no .cpp at all fails too, since a scan
that examines nothing reports "all Catch2" as loudly as one that examined
everything. Mutation-tested: restoring qml_surface.cpp to testkit/ takes the
gate from "All 58 rung-filter checks passed" to "1 of 58 ... failed", naming
the file.

scripts/test_check_rung_filters.sh pins all three new rejections and now copies
the real test sources into its scratch tree rather than the .clang-tidy files
alone -- a synthetic corpus would go on passing after the real tree had grown
a library source in one of these directories, which is the failure this is
about. In one `cp -r` per directory, not per file: per-file copying cost six
thousand processes and took the self-test from 50s to five minutes.

scripts/coverage.sh and examples/TESTING.md are updated where they describe
the old layout; codecov.yml needs no change, since its globs name
testkit/test_*.cpp and the moved files are under examples/common either way.

The headers are listed as sources of morph_ladder_testkit, and that is what
keeps the move from breaking the build. CMake's AUTOMOC documentation gives
exactly two ways a Q_OBJECT header reaches the scan list: "all header files in
the target's sources", and a same-base-name header found beside each C++
source. The move broke the second, fault_proxy.hpp declares a QObject, and
nothing generated moc_fault_proxy.cpp any more. Compilation is untouched and
the static library archives clean, so this is invisible until the first link
that needs the vtable -- which is six CI legs, not one.

Measured on a build directory configured from empty, clang 22.1.8 + Ninja,
-DMORPH_BUILD_QT=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all
-DMORPH_BUILD_TESTS=ON -DMORPH_BUILD_NET=ON -DMORPH_BUILD_OFFLINE_SQLITE=ON.
Without the two header entries:

  examples/common/morph_ladder_testkit_autogen/mocs_compilation.cpp
  // No files found that require moc or the moc files are included

  $ nm -C examples/common/libmorph_ladder_testkit.a | grep staticMetaObject
                   U morph::ladder::testkit::FaultProxy::staticMetaObject

  /usr/bin/ld: examples/common/libmorph_ladder_testkit.a(fault_proxy.cpp.o):
    in function `morph::ladder::testkit::FaultProxy::FaultProxy(QUrl, QObject*)':
    undefined reference to `vtable for morph::ladder::testkit::FaultProxy'
  /usr/bin/ld: ... undefined reference to
    `morph::ladder::testkit::FaultProxy::staticMetaObject'
  /usr/bin/ld: ... undefined reference to
    `typeinfo for morph::ladder::testkit::FaultProxy'
  clang++: error: linker command failed with exit code 1

With them, from a second empty directory and the same configure:

  examples/common/morph_ladder_testkit_autogen/C4OGJZPURZ/moc_fault_proxy.cpp
  0000000000000038 D morph::ladder::testkit::FaultProxy::staticMetaObject
  0000000000000070 D vtable for morph::ladder::testkit::FaultProxy
  00000000000000e0 D typeinfo for morph::ladder::testkit::FaultProxy

and the whole tree builds: 441 ninja edges, exit 0, every rung's test binary
linked. moc writes `#include "testkit/fault_proxy.hpp"` -- the header is under
the target's examples/common include directory, so CMAKE_AUTOMOC_PATH_PREFIX
resolves it and nothing ascends; scripts/check_automoc_includes.sh over that
tree reports 32 generated moc sources, none ascending.

Header entries are not compiled; they only join the AUTOMOC scan. This is the
remedy cmake/morph_add_rung.cmake already applies to every rung, for the same
reason and with the same failure behind it -- its _lib_headers glob exists
because a rung splits include/<rung>/app/app.hpp from src/app/app.cpp, and
pastebin::app::App is where this repository last hit this link error. Both
headers are listed rather than only the one carrying Q_OBJECT today, so the
split is moc-neutral by construction instead of by which header happens to
need moc.

The other two candidates. Moving the headers down beside their TUs rewrites
every <testkit/...> include in the tree and gives the .clang-tidy argument
nothing it did not already have. An AUTOMOC search path does not exist: of the
properties CMake offers, AUTOMOC_INCLUDE_DIRECTORIES sets the -I list handed
to moc rather than the header-to-source pairing, CMAKE_AUTOMOC_RELAXED_MODE
concerns "foo.moc" self-includes, and AUTOMOC_MACRO_NAMES,
AUTOMOC_DEPEND_FILTERS and AUTOMOC_MOC_OPTIONS leave discovery alone -- so
listing the headers is the documented mechanism, not a workaround for it.

check 5 is unaffected and still holds: copying both TUs back into
examples/common/testkit/ on this tree takes scripts/check_rung_filters.sh from
"All 58 rung-filter checks passed" to "1 of 58 rung-filter check(s) failed",
naming examples/common/testkit/fault_proxy.cpp and .../qml_surface.cpp. A
header in that directory is not a translation unit and the scan does not see
it.

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

The gate read a list of paths from stdin and answered `Spec sync OK: the
change touches no files.` to an empty one. The sentence is honest -- it claims
nothing was checked rather than claiming a check passed -- which is why this
is narrower than the usual version of this defect. The exposure is not a lying
gate but a broken upstream pipe producing a green tick.

First, whether that exposure is live. CI's own invocation is not exposed, and
this was measured rather than read off the YAML: spec-sync.yml's step runs
under `set -euo pipefail`, assigns the diff to a variable, and pipes a
`printf`, so a failing `git diff` aborts the step before the script is
reached. Driving that exact shape with a bad ref:

    -- shape A: the workflow's own (assignment under set -e, then printf | checker)
    fatal: bad object deadbeefdeadbeef0000000000000000deadbeef
    step exit=128

The documented *manual* shape, in a shell with neither -e nor pipefail -- an
interactive one, or a pre-push hook, which is how the filer found this -- is
exposed exactly as filed:

    $ git diff --name-only <a bad ref> HEAD | bash scripts/check_spec_sync.sh
    fatal: bad object deadbeefdeadbeef0000000000000000deadbeef
    Spec sync OK: the change touches no files.
    pipeline exit=0

and after this change:

    fatal: bad object deadbeefdeadbeef0000000000000000deadbeef
    ::error::spec-sync: the path list on stdin is empty, and --allow-empty was not given.
    pipeline exit=1

An explicit `--allow-empty` rather than making empty input an error outright:
"this change touches no headers" is a real and common outcome the gate must
still pass, so the point is to make silence something a caller states rather
than something it falls into. An unrecognised argument exits 2 rather than
being ignored, since a typo'd `--allow_empty` that the script shrugged off
would wedge every legitimately empty invocation.

spec-sync.yml passes the flag only when it has *observed* the emptiness --
`set -euo pipefail` means the git diff either produced the list or aborted the
step, so an empty `$changed` there is a real net-zero diff (a pull request
whose commits cancel out) and not an upstream failure. Passing it
unconditionally would hand that entitlement straight back.

Both paths are pinned in scripts/test_check_spec_sync.sh, plus whitespace-only
input (which is what `printf '%s\n' "$changed"` produces for an empty
variable), an unrecognised argument, and --allow-empty over a real violation,
which must still be rejected. Rejecting empty is useless if --allow-empty
stops working, and accepting it with the flag is useless if the flag is the
only path anyone tests.

Mutation-tested in both directions, since a self-test that passes against a
feature that does nothing is the thing this repository distrusts. Forcing the
empty branch to always allow: "NOT caught: an empty path list with no
--allow-empty", 2 self-test checks fail. Forcing it to always reject: "FALSE
POSITIVE: an empty path list with --allow-empty", 1 fails. Unmutated, all 23
pass.

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 of the fix

The amend was the right call and it did what it claimed. Comparing patches against the pre-rebase originals:

#651 patch identical to 3adbaee9:  True
#655 patch identical to d380895c:  True
#652 patch changed:                True  (two header entries + the reasoning block)

Amending rather than stacking was correct: the #652 commit as it stood did not build, and leaving it in history puts a broken tree under git bisect for anything touching examples/. The two untouched commits are byte-identical, which is the part worth checking rather than trusting.

The precedent that justified option 1 is real, and it is nearly verbatim. cmake/morph_add_rung.cmake:128-136:

"…those apart (include/<rung>/app/app.hpp vs src/app/app.cpp), so a QObject declared in include/ gets no moc output at all otherwise — … at the link that actually needs the vtable (pastebin::app::App, hit the moment ladder_pastebin_tests linked it). Header entries are not compiled; they only join the AUTOMOC scan."

file(GLOB_RECURSE _lib_headers CONFIGURE_DEPENDS "${_dir}/include/*.hpp")
add_library(ladder_${_rung}_lib STATIC ${_lib_sources} ${_lib_headers})

So this is the second occurrence of a failure mode this repository had already hit, diagnosed and written down — and the fix is the same remedy in the same idiom, not a workaround. That is a much stronger justification than "it makes the link work", and it is the reason #659 (a gate for the class) is worth having: the written-down comment did not prevent the recurrence, because nothing reads a comment in a different file.

Rejecting option 3 on checked grounds rather than from memory is the other thing I want on the record. AUTOMOC_INCLUDE_DIRECTORIES sets moc's -I list, not the pairing; CMAKE_AUTOMOC_RELAXED_MODE is about .moc self-includes; the rest leave discovery alone. Checking cmake --help-property-list and each property's own docs, rather than asserting a plausible-sounding property exists, is what stops a false reason entering the tree.

Listing both headers, not just the one carrying Q_OBJECT today, is the detail that makes this durable — the split is moc-neutral by construction rather than by which header currently needs moc.

Correcting the record is noted and matters. The previous revision claimed morph_ladder_testkit and bank_gui_tests "both link"; that was measured on a warm tree still holding the pre-move moc_fault_proxy.cpp and was false on a fresh configure. Saying so in the PR body, rather than quietly replacing the claim, is the right handling — and the cold-build discipline (repro first on the pre-fix tree, then the fix, then a third dir with the bank GUI) is exactly what the failure demanded.

check-5 is unweakened, verified by real-tree mutation rather than the synthetic corpus: copying both TUs back into testkit/ reproduces 1 of 58 rung-filter check(s) failed naming exactly those two files; unmutated is All 58 … passed. Headers are not .cpp, so listing them as sources cannot touch that condition.

Not verified by me: the cold-build link evidence itself and the GCC leg — the lane's own note that AUTOMOC generation is configure-time and compiler-independent is inference, not measurement, and Linux / all optional features (gcc) is one of the six that must now come back green.

Stale worktrees pruned. Not merged: CI incomplete at hand-off.

🤖 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
Yaraslaut merged commit 7ab4c7a into master Sep 21, 2026
52 checks passed
Yaraslaut added a commit that referenced this pull request Sep 21, 2026
…ixes #656)

#657 added -DMORPH_BUILD_BANK_GUI=ON to the clang-tidy job's Configure step,
which put eleven bank-GUI sources into compile_commands.json for the first
time. clang-tidy-diff only reports on changed lines, so nothing went red --
the findings were waiting for whoever next edited one of those lines.

Re-measured on 7ab4c7a before touching anything, clang-tidy 22.1.8 (ci.yml
pins CLANG_VERSION: "22"), Qt 6.11.2, against the clang-tidy job's own
configure flags and its own -extra-arg pair, cold build directory:

    97 findings inside the eleven sources themselves

    44  cppcoreguidelines-pro-bounds-avoid-unchecked-container-access
    23  performance-unnecessary-value-param
     9  misc-const-correctness
     8  readability-identifier-length
     2  readability-static-accessed-through-instance
     2  readability-implicit-bool-conversion
     2  readability-avoid-nested-conditional-operator
     2  modernize-use-auto
     1  readability-function-cognitive-complexity
     1  readability-container-size-empty
     1  cppcoreguidelines-pro-bounds-constant-array-index
     1  concurrency-mt-unsafe
     1  cppcoreguidelines-avoid-c-arrays + modernize-avoid-c-arrays (one site,
        two check names, which is why the issue's per-check list sums to 98)

Exactly the count #656 filed on 4563aff, file-for-file and check-for-check.
Same command after this commit: 0.

The two big checks were two mechanical passes, as the ticket predicted:

  - 44 `map[QStringLiteral("k")] = v` on QVariantMap become
    `map.insert(QStringLiteral("k"), v)`. QMap::operator[] on a non-const map
    inserts a default and hands back a reference; insert() does the same
    lookup and assignment in one call, so this is the same map with no
    bounds-unchecked accessor in it.
  - 23 by-value continuation parameters become const references.
    Completion<T>::then takes std::function<void(const T&)>, so every one of
    these was copying a DTO out of a reference the caller already held.

The residual 30 needed judgement, one at a time:

  - `id` parameters (5) are renamed for what they identify -- cardId, payeeId,
    accountId -- in the headers too. QML binds Q_INVOKABLE arguments
    positionally, so no .qml file sees this.
  - CardController's two nested conditional operators become one if/else-if
    over CardStatus with Cancelled as the fall-through, which is also what
    stopped the two `statusText`/`statusKind` chains being read twice.
  - main.cpp: setApplicationName and exec are static on QCoreApplication, so
    they are called that way and `app` becomes const; std::getenv is
    concurrency-mt-unsafe and becomes qgetenv, which the two seed variables
    twenty lines below already used; the `const char* names[5]` becomes a
    QStringList indexed with .at(), which removes the C-array pair and the
    non-constant array index together; two `if (window)` become explicit
    null comparisons.
  - Both of the behaviour test's TEST_CASEs carry a reasoned
    NOLINTNEXTLINE(readability-function-cognitive-complexity), and the whole
    argument for both sits above the first one. The check scores a whole
    Catch2 TEST_CASE body -- clang-tidy names them `dummyFunction72` and
    `dummyFunction76` -- and what it scores here is Catch2's assertion
    expansion rather than a branch thicket: REQUIRE/CHECK expand to a
    do-while around a try/catch with a `&&` in the loop condition, which the
    metric charges +1/+2/+1, four points per assertion. Measured with the
    clang-tidy job's own configure and its own -extra-arg pair, clang-tidy
    22.1.8, threshold lowered to 1 so both cases report rather than only the
    one over:

        dummyFunction72  "MoveMoneyPage's picker ..."   87, 21 REQUIRE/CHECK
        dummyFunction76  "Main.qml confirms ..."        45, 11 REQUIRE/CHECK

    21 x 4 = 84 and 11 x 4 = 44, so three points of the 87 and one of the 45
    are the whole of what the tests' own shape contributes. Commenting a
    single CHECK out of the first case moves it 87 -> 83, so four-per-
    assertion is measured and not arithmetic. Both numbers are identical on
    7ab4c7a: neither is a regression this commit introduced.

    An earlier revision of this commit suppressed only the second case and
    stated, in the tree, that the first "scores under the threshold and stays
    covered". That was never measured. It was read off the finding not being
    *reported*, which is a different thing: clang-tidy-diff surfaces a
    finding only when one of its notes lands on a changed line, and on
    7ab4c7a none of the first case's notes was on one. Renaming the
    `balanceOf` lambda's parameter put a changed line under one of them, and
    the job went red with

        test_bank_gui_qml_behaviour.cpp:115:1: error: function
        'dummyFunction72' has cognitive complexity of 87 (threshold 25)
        [readability-function-cognitive-complexity,-warnings-as-errors]

    Splitting the first case was the alternative, and it cannot reach the
    threshold. At four points an assertion, 25 allows six assertions per
    TEST_CASE; that case's prologue alone -- register a user, open two
    accounts, stand up a QQmlEngine, load MoveMoneyPage.qml, drive the picker
    onto the savings account -- is eight, so every fragment is over before it
    asserts anything of its own. Hoisting the prologue into a helper
    relocates the score rather than removing it, and morph#296's defect *is*
    the sequence (pick, deposit, still picked, deposit again, the money
    followed the label) that a split would scatter.

    Two per-case directives rather than one entry in
    examples/bank/tests/.clang-tidy, which would subtract the check from
    every bank test including ones not yet written, and rather than a
    NOLINTBEGIN/NOLINTEND span, which would cover whatever is added between
    them. Both reasons sit above their directive, not wrapped around it
    (#631).

No new .clang-tidy anywhere, and examples/bank/tests/.clang-tidy is untouched:
widening it would be #652's mistake one directory over.

Verified:
  - 97 -> 0 in-source findings, same command, same build directory. The run
    still reports 1743 diagnostics in include/morph/** headers from these
    eleven TUs and zero clang-diagnostic-error, so the analysis is live rather
    than silently skipping the files.
  - Anti-vacuity: reinstating one `map[...] = ...` in PayeeController.cpp
    brings the finding straight back
    (`PayeeController.cpp:52:20: error: possibly unsafe 'operator[]' ...`),
    so the zero is a measurement and not an empty walk.
  - Builds clean under clang 22.1.8 (-Weverything -Werror, clang-debug) and
    under gcc (gcc-debug), both configured from empty with
    -DMORPH_BUILD_BANK_GUI=ON.
  - bank_gui_tests and bank_gui_qml_tests both pass, all three cases.
  - The 97 -> 0 re-checked whole-file against the runner's own Catch2
    series (3.5.3 headers ahead of the workstation's on -isystem), because
    the workstation's Catch2 cannot see this particular check at all (see
    below): all eleven sources, full check set, 0 findings inside them and
    0 clang-diagnostic-error.
  - The clang-tidy-diff gate itself, reproduced locally rather than inferred.
    clang-tidy-diff.py with this job's -path/-p1/-extra-arg set, over
    `git diff -U0 origin/master`, exits 1 with exactly the CI error quoted
    above before the two-directive correction, and exits 0 after it -- over
    the branch's whole diff, not just the one file.
  - Anti-vacuity for the second directive, which this branch's own diff does
    not exercise at all: with the directive deleted, the same gate over a
    one-line diff on that case's `REQUIRE(pumpUntil([&app] { ... }))` line
    reports `dummyFunction76 ... cognitive complexity of 45 (threshold 25)`
    and exits 1; with the directive back, that diff exits 0. A one-line diff
    on a non-lambda assertion line in the same case reports nothing either
    way, which is why the shipped diff never reached it.

Not verified: the Emscripten leg. wasm-demo.yml builds bank_gui_wasm from
these same controller sources and no Qt-WASM toolchain is installed here.

Also not verified locally in the CI configuration exactly: reproducing the
clang-tidy-diff failure needed Catch2 3.5.x headers on the include path to
match the runner's apt `catch2`. With the workstation's Catch2 3.16.0
clang-tidy computes the same 87 and 45 but drops both findings as non-user
code, so a bare local run of the gate is green on a diff CI fails. Filed as
#666, with #667 for the Catch2 version the .clang-tidy copies record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Yaraslaut added a commit that referenced this pull request Sep 21, 2026
…ixes #659)

AUTOMOC finds a Q_OBJECT header two ways -- beside a translation unit of the
same basename, or named in a target's own source list -- and when neither
holds it generates nothing and says nothing. The .cpp compiles, the static
library archives, and the first signal is a linker error about a missing
vtable in every leg that links the target. On #657 that was six red legs at
once, the fastest at 4m03s.

cmake/morph_add_rung.cmake describes this failure in its own comment on the
_lib_headers glob, names the case it was diagnosed on (pastebin::app::App,
"hit the moment ladder_pastebin_tests linked it") and gives the remedy. #652
hit it again anyway, in a different CMakeLists. A comment in a file you are
not editing is not a control.

scripts/check_qobject_moc_pairing.py is. For every tracked header carrying an
AUTOMOC macro it requires one of three things, and each is checked rather
than assumed:

  - a translation unit of the same basename in the same directory;
  - the header named in a target's source list, parsed out of the CMake
    corpus by balanced-paren command extraction, with `#` comments blanked
    first (examples/common/CMakeLists.txt names fault_proxy.hpp five times in
    the paragraph explaining why it is listed; counting those would let the
    prose about the coverage stand in for the coverage) and FILE_SET argument
    blocks dropped (morph_qt's installed-header set names
    qt_websocket_server.hpp but drives no moc; morph_qt_impl's source list is
    what does);
  - the header under examples/<rung>/include/ for a rung in
    examples/rungs.txt -- the morph_add_rung() glob. This gate does not
    resolve CMake globs, so it asserts that one instead: it fails if
    morph_add_rung.cmake stops carrying a
    `file(GLOB_RECURSE _lib_headers ... include/*.hpp)` whose result reaches
    an add_library(). Four of the tree's six split headers are covered by
    nothing else.

Measured on this tree: 349 tracked headers, 41 CMake files, 40 headers
carrying an AUTOMOC macro -- 34 paired, 2 listed, 4 globbed, 0 uncovered.

## Why a text scan, and why drift-guard.yml

#659 expected a gate over a configured build tree, as
scripts/check_automoc_includes.sh is, and flagged the scoping problem: a build
tree only holds what its configure enabled, so "every Q_OBJECT header must
have moc output" false-positives on everything behind an off-by-default
option -- the WASM shells, bank's GUI, every rung at MORPH_BUILD_LADDER=OFF.

Checking the pairing rather than the output dissolves that. A header behind an
off-by-default option still has to be listed in its conditionally-added
target; which options a configure turned on does not enter into it. So this
needs no configure, no compiler and no Qt, and fits drift-guard.yml's stated
contract ("Every job here is fast and dependency-free; none of them compiles
anything") rather than sitting behind the slow legs it exists to pre-empt.

It deliberately does not check that the target owning the source list has
AUTOMOC on -- that is a second way to get no moc output, it has never happened
here, and resolving target properties means a configure. Recorded in the
script's header rather than left implicit.

## Both vacuity traps, closed

The tree is clean today, so this gate ships already green and would never
announce a broken scan on its own.

  - It prints what it examined -- headers walked, headers carrying a macro,
    and which mechanism covered each -- and exits 1 when the macro-bearing set
    is empty. A scan that stops recognising Q_OBJECT is a failure, not a pass.
  - `--self-test` drives nine fixtures, two of them mutations of this
    repository's real files:

      fixture 6b: `    testkit/fault_proxy.hpp` deleted from
      morph_ladder_testkit's source list in a copy of the real
      examples/common/CMakeLists.txt -- the exact #652 regression, and the
      close condition #659 names. The gate reports
      `examples/common/testkit/fault_proxy.hpp` and exits 1.

      fixture 7b: the `file(GLOB_RECURSE _lib_headers ...)` line deleted from
      a copy of the real cmake/morph_add_rung.cmake, with a rung header
      credited to it. The gate exits 1 rather than keeping the credit.

    Each mutation asserts that it changed something, so a rename upstream
    turns the self-test red instead of quietly making it a no-op. The other
    seven cover the paired arm, the listed arm, an unlisted split, a header
    "covered" only by a comment and a FILE_SET, and a tree with no macro
    headers at all.

## Verification status

Reproduced locally on 7ab4c7a + the #656 commit, python 3, no build:

    $ python3 scripts/check_qobject_moc_pairing.py
    walked 349 tracked header(s) across 41 CMake file(s)
    40 carry an AUTOMOC macro:
        34 paired with a same-directory translation unit
        2 named in a target's source list
        4 under a ladder rung's include/, globbed by morph_add_rung()
        0 with no moc pairing at all
    ok: morph_add_rung() globs include/*.hpp into ladder_<rung>_lib
    Q_OBJECT moc-pairing lint OK.

    $ python3 scripts/check_qobject_moc_pairing.py --self-test
    ... self-test OK: 9 fixture(s), including the #652 mutation of the real
    examples/common/CMakeLists.txt and a mutation of the real
    cmake/morph_add_rung.cmake.

Not verified: the gate against the pre-fix revision d380895 itself. The
mutation in fixture 6b reconstructs that state from the current file rather
than checking the old one out, so it proves the gate fires on the shape, not
that it would have fired on that commit's whole tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Yaraslaut added a commit that referenced this pull request Sep 21, 2026
…ixes #656)

#657 added -DMORPH_BUILD_BANK_GUI=ON to the clang-tidy job's Configure step,
which put eleven bank-GUI sources into compile_commands.json for the first
time. clang-tidy-diff only reports on changed lines, so nothing went red --
the findings were waiting for whoever next edited one of those lines.

Re-measured on 7ab4c7a before touching anything, clang-tidy 22.1.8 (ci.yml
pins CLANG_VERSION: "22"), Qt 6.11.2, against the clang-tidy job's own
configure flags and its own -extra-arg pair, cold build directory:

    97 findings inside the eleven sources themselves

    44  cppcoreguidelines-pro-bounds-avoid-unchecked-container-access
    23  performance-unnecessary-value-param
     9  misc-const-correctness
     8  readability-identifier-length
     2  readability-static-accessed-through-instance
     2  readability-implicit-bool-conversion
     2  readability-avoid-nested-conditional-operator
     2  modernize-use-auto
     1  readability-function-cognitive-complexity
     1  readability-container-size-empty
     1  cppcoreguidelines-pro-bounds-constant-array-index
     1  concurrency-mt-unsafe
     1  cppcoreguidelines-avoid-c-arrays + modernize-avoid-c-arrays (one site,
        two check names, which is why the issue's per-check list sums to 98)

Exactly the count #656 filed on 4563aff, file-for-file and check-for-check.
Same command after this commit: 0.

The two big checks were two mechanical passes, as the ticket predicted:

  - 44 `map[QStringLiteral("k")] = v` on QVariantMap become
    `map.insert(QStringLiteral("k"), v)`. QMap::operator[] on a non-const map
    inserts a default and hands back a reference; insert() does the same
    lookup and assignment in one call, so this is the same map with no
    bounds-unchecked accessor in it.
  - 23 by-value continuation parameters become const references.
    Completion<T>::then takes std::function<void(const T&)>, so every one of
    these was copying a DTO out of a reference the caller already held.

The residual 30 needed judgement, one at a time:

  - `id` parameters (5) are renamed for what they identify -- cardId, payeeId,
    accountId -- in the headers too. QML binds Q_INVOKABLE arguments
    positionally, so no .qml file sees this.
  - CardController's two nested conditional operators become one if/else-if
    over CardStatus with Cancelled as the fall-through, which is also what
    stopped the two `statusText`/`statusKind` chains being read twice.
  - main.cpp: setApplicationName and exec are static on QCoreApplication, so
    they are called that way and `app` becomes const; std::getenv is
    concurrency-mt-unsafe and becomes qgetenv, which the two seed variables
    twenty lines below already used; the `const char* names[5]` becomes a
    QStringList indexed with .at(), which removes the C-array pair and the
    non-constant array index together; two `if (window)` become explicit
    null comparisons.
  - Both of the behaviour test's TEST_CASEs carry a reasoned
    NOLINTNEXTLINE(readability-function-cognitive-complexity), and the whole
    argument for both sits above the first one. The check scores a whole
    Catch2 TEST_CASE body -- clang-tidy names them `dummyFunction72` and
    `dummyFunction76` -- and what it scores here is Catch2's assertion
    expansion rather than a branch thicket: REQUIRE/CHECK expand to a
    do-while around a try/catch with a `&&` in the loop condition, which the
    metric charges +1/+2/+1, four points per assertion. Measured with the
    clang-tidy job's own configure and its own -extra-arg pair, clang-tidy
    22.1.8, threshold lowered to 1 so both cases report rather than only the
    one over:

        dummyFunction72  "MoveMoneyPage's picker ..."   87, 21 REQUIRE/CHECK
        dummyFunction76  "Main.qml confirms ..."        45, 11 REQUIRE/CHECK

    21 x 4 = 84 and 11 x 4 = 44, so three points of the 87 and one of the 45
    are the whole of what the tests' own shape contributes. Commenting a
    single CHECK out of the first case moves it 87 -> 83, so four-per-
    assertion is measured and not arithmetic. Both numbers are identical on
    7ab4c7a: neither is a regression this commit introduced.

    An earlier revision of this commit suppressed only the second case and
    stated, in the tree, that the first "scores under the threshold and stays
    covered". That was never measured. It was read off the finding not being
    *reported*, which is a different thing: clang-tidy-diff surfaces a
    finding only when one of its notes lands on a changed line, and on
    7ab4c7a none of the first case's notes was on one. Renaming the
    `balanceOf` lambda's parameter put a changed line under one of them, and
    the job went red with

        test_bank_gui_qml_behaviour.cpp:115:1: error: function
        'dummyFunction72' has cognitive complexity of 87 (threshold 25)
        [readability-function-cognitive-complexity,-warnings-as-errors]

    Splitting the first case was the alternative, and it cannot reach the
    threshold. At four points an assertion, 25 allows six assertions per
    TEST_CASE; that case's prologue alone -- register a user, open two
    accounts, stand up a QQmlEngine, load MoveMoneyPage.qml, drive the picker
    onto the savings account -- is eight, so every fragment is over before it
    asserts anything of its own. Hoisting the prologue into a helper
    relocates the score rather than removing it, and morph#296's defect *is*
    the sequence (pick, deposit, still picked, deposit again, the money
    followed the label) that a split would scatter.

    Two per-case directives rather than one entry in
    examples/bank/tests/.clang-tidy, which would subtract the check from
    every bank test including ones not yet written, and rather than a
    NOLINTBEGIN/NOLINTEND span, which would cover whatever is added between
    them. Both reasons sit above their directive, not wrapped around it
    (#631).

No new .clang-tidy anywhere, and examples/bank/tests/.clang-tidy is untouched:
widening it would be #652's mistake one directory over.

Verified:
  - 97 -> 0 in-source findings, same command, same build directory. The run
    still reports 1743 diagnostics in include/morph/** headers from these
    eleven TUs and zero clang-diagnostic-error, so the analysis is live rather
    than silently skipping the files.
  - Anti-vacuity: reinstating one `map[...] = ...` in PayeeController.cpp
    brings the finding straight back
    (`PayeeController.cpp:52:20: error: possibly unsafe 'operator[]' ...`),
    so the zero is a measurement and not an empty walk.
  - Builds clean under clang 22.1.8 (-Weverything -Werror, clang-debug) and
    under gcc (gcc-debug), both configured from empty with
    -DMORPH_BUILD_BANK_GUI=ON.
  - bank_gui_tests and bank_gui_qml_tests both pass, all three cases.
  - The 97 -> 0 re-checked whole-file against the runner's own Catch2
    series (3.5.3 headers ahead of the workstation's on -isystem), because
    the workstation's Catch2 cannot see this particular check at all (see
    below): all eleven sources, full check set, 0 findings inside them and
    0 clang-diagnostic-error.
  - The clang-tidy-diff gate itself, reproduced locally rather than inferred.
    clang-tidy-diff.py with this job's -path/-p1/-extra-arg set, over
    `git diff -U0 origin/master`, exits 1 with exactly the CI error quoted
    above before the two-directive correction, and exits 0 after it -- over
    the branch's whole diff, not just the one file.
  - Anti-vacuity for the second directive, which this branch's own diff does
    not exercise at all: with the directive deleted, the same gate over a
    one-line diff on that case's `REQUIRE(pumpUntil([&app] { ... }))` line
    reports `dummyFunction76 ... cognitive complexity of 45 (threshold 25)`
    and exits 1; with the directive back, that diff exits 0. A one-line diff
    on a non-lambda assertion line in the same case reports nothing either
    way, which is why the shipped diff never reached it.

Not verified: the Emscripten leg. wasm-demo.yml builds bank_gui_wasm from
these same controller sources and no Qt-WASM toolchain is installed here.

Also not verified locally in the CI configuration exactly: reproducing the
clang-tidy-diff failure needed Catch2 3.5.x headers on the include path to
match the runner's apt `catch2`. With the workstation's Catch2 3.16.0
clang-tidy computes the same 87 and 45 but drops both findings as non-user
code, so a bare local run of the gate is green on a diff CI fails. Filed as
#666, with #667 for the Catch2 version the .clang-tidy copies record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Yaraslaut added a commit that referenced this pull request Sep 21, 2026
…ixes #659)

AUTOMOC finds a Q_OBJECT header two ways -- beside a translation unit of the
same basename, or named in a target's own source list -- and when neither
holds it generates nothing and says nothing. The .cpp compiles, the static
library archives, and the first signal is a linker error about a missing
vtable in every leg that links the target. On #657 that was six red legs at
once, the fastest at 4m03s.

cmake/morph_add_rung.cmake describes this failure in its own comment on the
_lib_headers glob, names the case it was diagnosed on (pastebin::app::App,
"hit the moment ladder_pastebin_tests linked it") and gives the remedy. #652
hit it again anyway, in a different CMakeLists. A comment in a file you are
not editing is not a control.

scripts/check_qobject_moc_pairing.py is. For every tracked header carrying an
AUTOMOC macro it requires one of three things, and each is checked rather
than assumed:

  - a translation unit of the same basename in the same directory;
  - the header named in a target's source list, parsed out of the CMake
    corpus by balanced-paren command extraction, with `#` comments blanked
    first (examples/common/CMakeLists.txt names fault_proxy.hpp five times in
    the paragraph explaining why it is listed; counting those would let the
    prose about the coverage stand in for the coverage) and FILE_SET argument
    blocks dropped (morph_qt's installed-header set names
    qt_websocket_server.hpp but drives no moc; morph_qt_impl's source list is
    what does);
  - the header under examples/<rung>/include/ for a rung in
    examples/rungs.txt -- the morph_add_rung() glob. This gate does not
    resolve CMake globs, so it asserts that one instead: it fails if
    morph_add_rung.cmake stops carrying a
    `file(GLOB_RECURSE _lib_headers ... include/*.hpp)` whose result reaches
    an add_library(). Four of the tree's six split headers are covered by
    nothing else.

Measured on this tree: 349 tracked headers, 41 CMake files, 40 headers
carrying an AUTOMOC macro -- 34 paired, 2 listed, 4 globbed, 0 uncovered.

## Why a text scan, and why drift-guard.yml

#659 expected a gate over a configured build tree, as
scripts/check_automoc_includes.sh is, and flagged the scoping problem: a build
tree only holds what its configure enabled, so "every Q_OBJECT header must
have moc output" false-positives on everything behind an off-by-default
option -- the WASM shells, bank's GUI, every rung at MORPH_BUILD_LADDER=OFF.

Checking the pairing rather than the output dissolves that. A header behind an
off-by-default option still has to be listed in its conditionally-added
target; which options a configure turned on does not enter into it. So this
needs no configure, no compiler and no Qt, and fits drift-guard.yml's stated
contract ("Every job here is fast and dependency-free; none of them compiles
anything") rather than sitting behind the slow legs it exists to pre-empt.

It deliberately does not check that the target owning the source list has
AUTOMOC on -- that is a second way to get no moc output, it has never happened
here, and resolving target properties means a configure. Recorded in the
script's header rather than left implicit.

## Both vacuity traps, closed

The tree is clean today, so this gate ships already green and would never
announce a broken scan on its own.

  - It prints what it examined -- headers walked, headers carrying a macro,
    and which mechanism covered each -- and exits 1 when the macro-bearing set
    is empty. A scan that stops recognising Q_OBJECT is a failure, not a pass.
  - `--self-test` drives nine fixtures, two of them mutations of this
    repository's real files:

      fixture 6b: `    testkit/fault_proxy.hpp` deleted from
      morph_ladder_testkit's source list in a copy of the real
      examples/common/CMakeLists.txt -- the exact #652 regression, and the
      close condition #659 names. The gate reports
      `examples/common/testkit/fault_proxy.hpp` and exits 1.

      fixture 7b: the `file(GLOB_RECURSE _lib_headers ...)` line deleted from
      a copy of the real cmake/morph_add_rung.cmake, with a rung header
      credited to it. The gate exits 1 rather than keeping the credit.

    Each mutation asserts that it changed something, so a rename upstream
    turns the self-test red instead of quietly making it a no-op. The other
    seven cover the paired arm, the listed arm, an unlisted split, a header
    "covered" only by a comment and a FILE_SET, and a tree with no macro
    headers at all.

## Verification status

Reproduced locally on 7ab4c7a + the #656 commit, python 3, no build:

    $ python3 scripts/check_qobject_moc_pairing.py
    walked 349 tracked header(s) across 41 CMake file(s)
    40 carry an AUTOMOC macro:
        34 paired with a same-directory translation unit
        2 named in a target's source list
        4 under a ladder rung's include/, globbed by morph_add_rung()
        0 with no moc pairing at all
    ok: morph_add_rung() globs include/*.hpp into ladder_<rung>_lib
    Q_OBJECT moc-pairing lint OK.

    $ python3 scripts/check_qobject_moc_pairing.py --self-test
    ... self-test OK: 9 fixture(s), including the #652 mutation of the real
    examples/common/CMakeLists.txt and a mutation of the real
    cmake/morph_add_rung.cmake.

Not verified: the gate against the pre-fix revision d380895 itself. The
mutation in fixture 6b reconstructs that state from the current file rather
than checking the old one out, so it proves the gate fires on the shape, not
that it would have fired on that commit's whole tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Yaraslaut added a commit that referenced this pull request Sep 21, 2026
…d gate the Q_OBJECT header split that no build catches early (fixes #656, fixes #659) (#665)

* bank/gui: clear the 97 clang-tidy findings #657 brought into reach (fixes #656)

#657 added -DMORPH_BUILD_BANK_GUI=ON to the clang-tidy job's Configure step,
which put eleven bank-GUI sources into compile_commands.json for the first
time. clang-tidy-diff only reports on changed lines, so nothing went red --
the findings were waiting for whoever next edited one of those lines.

Re-measured on 7ab4c7a before touching anything, clang-tidy 22.1.8 (ci.yml
pins CLANG_VERSION: "22"), Qt 6.11.2, against the clang-tidy job's own
configure flags and its own -extra-arg pair, cold build directory:

    97 findings inside the eleven sources themselves

    44  cppcoreguidelines-pro-bounds-avoid-unchecked-container-access
    23  performance-unnecessary-value-param
     9  misc-const-correctness
     8  readability-identifier-length
     2  readability-static-accessed-through-instance
     2  readability-implicit-bool-conversion
     2  readability-avoid-nested-conditional-operator
     2  modernize-use-auto
     1  readability-function-cognitive-complexity
     1  readability-container-size-empty
     1  cppcoreguidelines-pro-bounds-constant-array-index
     1  concurrency-mt-unsafe
     1  cppcoreguidelines-avoid-c-arrays + modernize-avoid-c-arrays (one site,
        two check names, which is why the issue's per-check list sums to 98)

Exactly the count #656 filed on 4563aff, file-for-file and check-for-check.
Same command after this commit: 0.

The two big checks were two mechanical passes, as the ticket predicted:

  - 44 `map[QStringLiteral("k")] = v` on QVariantMap become
    `map.insert(QStringLiteral("k"), v)`. QMap::operator[] on a non-const map
    inserts a default and hands back a reference; insert() does the same
    lookup and assignment in one call, so this is the same map with no
    bounds-unchecked accessor in it.
  - 23 by-value continuation parameters become const references.
    Completion<T>::then takes std::function<void(const T&)>, so every one of
    these was copying a DTO out of a reference the caller already held.

The residual 30 needed judgement, one at a time:

  - `id` parameters (5) are renamed for what they identify -- cardId, payeeId,
    accountId -- in the headers too. QML binds Q_INVOKABLE arguments
    positionally, so no .qml file sees this.
  - CardController's two nested conditional operators become one if/else-if
    over CardStatus with Cancelled as the fall-through, which is also what
    stopped the two `statusText`/`statusKind` chains being read twice.
  - main.cpp: setApplicationName and exec are static on QCoreApplication, so
    they are called that way and `app` becomes const; std::getenv is
    concurrency-mt-unsafe and becomes qgetenv, which the two seed variables
    twenty lines below already used; the `const char* names[5]` becomes a
    QStringList indexed with .at(), which removes the C-array pair and the
    non-constant array index together; two `if (window)` become explicit
    null comparisons.
  - Both of the behaviour test's TEST_CASEs carry a reasoned
    NOLINTNEXTLINE(readability-function-cognitive-complexity), and the whole
    argument for both sits above the first one. The check scores a whole
    Catch2 TEST_CASE body -- clang-tidy names them `dummyFunction72` and
    `dummyFunction76` -- and what it scores here is Catch2's assertion
    expansion rather than a branch thicket: REQUIRE/CHECK expand to a
    do-while around a try/catch with a `&&` in the loop condition, which the
    metric charges +1/+2/+1, four points per assertion. Measured with the
    clang-tidy job's own configure and its own -extra-arg pair, clang-tidy
    22.1.8, threshold lowered to 1 so both cases report rather than only the
    one over:

        dummyFunction72  "MoveMoneyPage's picker ..."   87, 21 REQUIRE/CHECK
        dummyFunction76  "Main.qml confirms ..."        45, 11 REQUIRE/CHECK

    21 x 4 = 84 and 11 x 4 = 44, so three points of the 87 and one of the 45
    are the whole of what the tests' own shape contributes. Commenting a
    single CHECK out of the first case moves it 87 -> 83, so four-per-
    assertion is measured and not arithmetic. Both numbers are identical on
    7ab4c7a: neither is a regression this commit introduced.

    An earlier revision of this commit suppressed only the second case and
    stated, in the tree, that the first "scores under the threshold and stays
    covered". That was never measured. It was read off the finding not being
    *reported*, which is a different thing: clang-tidy-diff surfaces a
    finding only when one of its notes lands on a changed line, and on
    7ab4c7a none of the first case's notes was on one. Renaming the
    `balanceOf` lambda's parameter put a changed line under one of them, and
    the job went red with

        test_bank_gui_qml_behaviour.cpp:115:1: error: function
        'dummyFunction72' has cognitive complexity of 87 (threshold 25)
        [readability-function-cognitive-complexity,-warnings-as-errors]

    Splitting the first case was the alternative, and it cannot reach the
    threshold. At four points an assertion, 25 allows six assertions per
    TEST_CASE; that case's prologue alone -- register a user, open two
    accounts, stand up a QQmlEngine, load MoveMoneyPage.qml, drive the picker
    onto the savings account -- is eight, so every fragment is over before it
    asserts anything of its own. Hoisting the prologue into a helper
    relocates the score rather than removing it, and morph#296's defect *is*
    the sequence (pick, deposit, still picked, deposit again, the money
    followed the label) that a split would scatter.

    Two per-case directives rather than one entry in
    examples/bank/tests/.clang-tidy, which would subtract the check from
    every bank test including ones not yet written, and rather than a
    NOLINTBEGIN/NOLINTEND span, which would cover whatever is added between
    them. Both reasons sit above their directive, not wrapped around it
    (#631).

No new .clang-tidy anywhere, and examples/bank/tests/.clang-tidy is untouched:
widening it would be #652's mistake one directory over.

Verified:
  - 97 -> 0 in-source findings, same command, same build directory. The run
    still reports 1743 diagnostics in include/morph/** headers from these
    eleven TUs and zero clang-diagnostic-error, so the analysis is live rather
    than silently skipping the files.
  - Anti-vacuity: reinstating one `map[...] = ...` in PayeeController.cpp
    brings the finding straight back
    (`PayeeController.cpp:52:20: error: possibly unsafe 'operator[]' ...`),
    so the zero is a measurement and not an empty walk.
  - Builds clean under clang 22.1.8 (-Weverything -Werror, clang-debug) and
    under gcc (gcc-debug), both configured from empty with
    -DMORPH_BUILD_BANK_GUI=ON.
  - bank_gui_tests and bank_gui_qml_tests both pass, all three cases.
  - The 97 -> 0 re-checked whole-file against the runner's own Catch2
    series (3.5.3 headers ahead of the workstation's on -isystem), because
    the workstation's Catch2 cannot see this particular check at all (see
    below): all eleven sources, full check set, 0 findings inside them and
    0 clang-diagnostic-error.
  - The clang-tidy-diff gate itself, reproduced locally rather than inferred.
    clang-tidy-diff.py with this job's -path/-p1/-extra-arg set, over
    `git diff -U0 origin/master`, exits 1 with exactly the CI error quoted
    above before the two-directive correction, and exits 0 after it -- over
    the branch's whole diff, not just the one file.
  - Anti-vacuity for the second directive, which this branch's own diff does
    not exercise at all: with the directive deleted, the same gate over a
    one-line diff on that case's `REQUIRE(pumpUntil([&app] { ... }))` line
    reports `dummyFunction76 ... cognitive complexity of 45 (threshold 25)`
    and exits 1; with the directive back, that diff exits 0. A one-line diff
    on a non-lambda assertion line in the same case reports nothing either
    way, which is why the shipped diff never reached it.

Not verified: the Emscripten leg. wasm-demo.yml builds bank_gui_wasm from
these same controller sources and no Qt-WASM toolchain is installed here.

Also not verified locally in the CI configuration exactly: reproducing the
clang-tidy-diff failure needed Catch2 3.5.x headers on the include path to
match the runner's apt `catch2`. With the workstation's Catch2 3.16.0
clang-tidy computes the same 87 and 45 but drops both findings as non-user
code, so a bare local run of the gate is green on a diff CI fails. Filed as
#666, with #667 for the Catch2 version the .clang-tidy copies record.

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

* ci: catch a Q_OBJECT header split from its TU before the link does (fixes #659)

AUTOMOC finds a Q_OBJECT header two ways -- beside a translation unit of the
same basename, or named in a target's own source list -- and when neither
holds it generates nothing and says nothing. The .cpp compiles, the static
library archives, and the first signal is a linker error about a missing
vtable in every leg that links the target. On #657 that was six red legs at
once, the fastest at 4m03s.

cmake/morph_add_rung.cmake describes this failure in its own comment on the
_lib_headers glob, names the case it was diagnosed on (pastebin::app::App,
"hit the moment ladder_pastebin_tests linked it") and gives the remedy. #652
hit it again anyway, in a different CMakeLists. A comment in a file you are
not editing is not a control.

scripts/check_qobject_moc_pairing.py is. For every tracked header carrying an
AUTOMOC macro it requires one of three things, and each is checked rather
than assumed:

  - a translation unit of the same basename in the same directory;
  - the header named in a target's source list, parsed out of the CMake
    corpus by balanced-paren command extraction, with `#` comments blanked
    first (examples/common/CMakeLists.txt names fault_proxy.hpp five times in
    the paragraph explaining why it is listed; counting those would let the
    prose about the coverage stand in for the coverage) and FILE_SET argument
    blocks dropped (morph_qt's installed-header set names
    qt_websocket_server.hpp but drives no moc; morph_qt_impl's source list is
    what does);
  - the header under examples/<rung>/include/ for a rung in
    examples/rungs.txt -- the morph_add_rung() glob. This gate does not
    resolve CMake globs, so it asserts that one instead: it fails if
    morph_add_rung.cmake stops carrying a
    `file(GLOB_RECURSE _lib_headers ... include/*.hpp)` whose result reaches
    an add_library(). Four of the tree's six split headers are covered by
    nothing else.

Measured on this tree: 349 tracked headers, 41 CMake files, 40 headers
carrying an AUTOMOC macro -- 34 paired, 2 listed, 4 globbed, 0 uncovered.

## Why a text scan, and why drift-guard.yml

#659 expected a gate over a configured build tree, as
scripts/check_automoc_includes.sh is, and flagged the scoping problem: a build
tree only holds what its configure enabled, so "every Q_OBJECT header must
have moc output" false-positives on everything behind an off-by-default
option -- the WASM shells, bank's GUI, every rung at MORPH_BUILD_LADDER=OFF.

Checking the pairing rather than the output dissolves that. A header behind an
off-by-default option still has to be listed in its conditionally-added
target; which options a configure turned on does not enter into it. So this
needs no configure, no compiler and no Qt, and fits drift-guard.yml's stated
contract ("Every job here is fast and dependency-free; none of them compiles
anything") rather than sitting behind the slow legs it exists to pre-empt.

It deliberately does not check that the target owning the source list has
AUTOMOC on -- that is a second way to get no moc output, it has never happened
here, and resolving target properties means a configure. Recorded in the
script's header rather than left implicit.

## Both vacuity traps, closed

The tree is clean today, so this gate ships already green and would never
announce a broken scan on its own.

  - It prints what it examined -- headers walked, headers carrying a macro,
    and which mechanism covered each -- and exits 1 when the macro-bearing set
    is empty. A scan that stops recognising Q_OBJECT is a failure, not a pass.
  - `--self-test` drives nine fixtures, two of them mutations of this
    repository's real files:

      fixture 6b: `    testkit/fault_proxy.hpp` deleted from
      morph_ladder_testkit's source list in a copy of the real
      examples/common/CMakeLists.txt -- the exact #652 regression, and the
      close condition #659 names. The gate reports
      `examples/common/testkit/fault_proxy.hpp` and exits 1.

      fixture 7b: the `file(GLOB_RECURSE _lib_headers ...)` line deleted from
      a copy of the real cmake/morph_add_rung.cmake, with a rung header
      credited to it. The gate exits 1 rather than keeping the credit.

    Each mutation asserts that it changed something, so a rename upstream
    turns the self-test red instead of quietly making it a no-op. The other
    seven cover the paired arm, the listed arm, an unlisted split, a header
    "covered" only by a comment and a FILE_SET, and a tree with no macro
    headers at all.

## Verification status

Reproduced locally on 7ab4c7a + the #656 commit, python 3, no build:

    $ python3 scripts/check_qobject_moc_pairing.py
    walked 349 tracked header(s) across 41 CMake file(s)
    40 carry an AUTOMOC macro:
        34 paired with a same-directory translation unit
        2 named in a target's source list
        4 under a ladder rung's include/, globbed by morph_add_rung()
        0 with no moc pairing at all
    ok: morph_add_rung() globs include/*.hpp into ladder_<rung>_lib
    Q_OBJECT moc-pairing lint OK.

    $ python3 scripts/check_qobject_moc_pairing.py --self-test
    ... self-test OK: 9 fixture(s), including the #652 mutation of the real
    examples/common/CMakeLists.txt and a mutation of the real
    cmake/morph_add_rung.cmake.

Not verified: the gate against the pre-fix revision d380895 itself. The
mutation in fixture 6b reconstructs that state from the current file rather
than checking the old one out, so it proves the gate fires on the shape, not
that it would have fired on that commit's whole tree.

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

Development

Successfully merging this pull request may close these issues.

1 participant