ci/testkit: three gates that did not cover what they appeared to (fixes #651, fixes #652, fixes #655) - #657
Conversation
Runner verificationThe new gate condition is non-vacuous, and I ran it both ways rather than reading it. Installed this branch's 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 ( 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 One correction to the reportThe branch's safety claim is stated as resting on "
So the claim holds as observed, not as guaranteed by configuration — A second, smaller oneThe #655 conclusion — that the exposure is not live in CI — holds, but not quite for the stated reason. I found no explicit A failed command substitution in an assignment aborts under The On #656Filing 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 Noted, not filed: Not merged: CI incomplete at hand-off. Next sweep counts the checks. 🤖 Generated with Claude Code |
Runner: six legs red, one cause — the #652 move broke AUTOMOC pairing
From The same six Diagnosis: add_library(morph_ladder_testkit STATIC
testkit_src/fault_proxy.cpp
testkit_src/qml_surface.cpp
)with the headers still in 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 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 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 Dispatching a lane whose whole batch is this PR, with this log. The other two commits (#651's flag, #655's 🤖 Generated with Claude Code |
#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
…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
d380895 to
9cb4190
Compare
Runner verification of the fixThe amend was the right call and it did what it claimed. Comparing patches against the pre-rebase originals: 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 The precedent that justified option 1 is real, and it is nearly verbatim.
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. Listing both headers, not just the one carrying Correcting the record is noted and matters. The previous revision claimed check-5 is unweakened, verified by real-tree mutation rather than the synthetic corpus: copying both TUs back into 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 Stale worktrees pruned. Not merged: CI incomplete at hand-off. 🤖 Generated with Claude Code |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…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
…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
…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
…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
…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>
Three tickets about gates that do not cover what they appear to. One commit
each, all three landed, nothing rejected.
fixes #651— theclang-tidy-diffjob's configure was missing one flagfixes #652— a suppression file that misdescribed its own reachfixes #655— a gate that answered OK to an empty path listFiled 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, thefix, 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=ONfor the clang-tidy jobMORPH_BUILD_BANK_GUIdefaults OFF and theclang-tidyjob 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:
examples/BANK_GUIBANK_GUIThe 11-file delta is exactly the nine under
examples/bank/gui/and the twounder
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 areupdated to the measured ones.
No new installs.
linux-all-featuresalready builds this option onubuntu-24.04with the identicaljurplel/install-qt-action@v4 modules: qtwebsocketsand the identical apt set, which is why this is a flagand not a job. Locally,
bank_gui_libandbank_gui_testsboth compile underthis configure (verified while working #652, which touches a source
bank_gui_testscompiles).The trap, measured before landing. Whole-file, with this step's own
-extra-argpair: 97 findings inside the eleven sources themselves,across ten of them (
BankController.cppis clean,BankClient.cpphas noneof its own). 44
cppcoreguidelines-pro-bounds-avoid-unchecked-container-access,23
performance-unnecessary-value-param, 9misc-const-correctness, 8readability-identifier-length, 13 others. Findings landing ininclude/morph/**headers are excluded from that count — those headers arealready 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, andclang-tidy-diffreports only on changed lines, so nothing goes red untilsomeone 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/andtests/compile_checks/are not translationunits 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-tidysubtractsbugprone-chained-comparisonand 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 < cin the code under test is still reported". That directory alsoheld
morph_ladder_testkit's two translation units, neither containing aREQUIRE.
Which of the three fixes, and what the others cost. Narrowing, not
rewording:
fault_proxy.cppandqml_surface.cppmove toexamples/common/testkit_src/, outside the file's reach, so the claim holdsby construction. Their headers stay in
testkit/— every consumer includesthem as
<testkit/...>, which is also what resolves the moved TU's owninclude.
suppression sitting over non-Catch2 code, which is the state tests/.clang-tidy's suppressions also silence include/morph headers, so a header gate driven by test TUs under-reports #632 objected
to.
examples/common/testkit/tests/reaches the sameend state and matches every other example's layout — but it is 21 renames
that also silently break
codecov.yml's twoexamples/common/testkit/test_*.cppignore globs. A Codecov-side changenothing in this repository would report is coverage.sh measures only rung 1: bookmarks, polls and kanban (15k lines of shipped rung code) are invisible to the coverage report #141's failure mode. This way is
four files and no glob depends on it.
Behavioural verification, with the repository's real
.clang-tidyfilesrather than a
--checksoverride. Areturn aaa < bbb < ccc;probeappended to the moved TU:
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:
Both probes were removed; neither is on this branch. What this buys today is
nothing: before the move,
--checks=-*,bugprone-chained-comparisonoverboth 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.cppa Catch2 suppression reaches, recursively, must itself be a Catch2 TU —a
TEST_CASE/SCENARIO, acatch2/include, orCATCH_CONFIG_*for aCatch2 main such as
testkit_main.cpp. Across the nine suppressed directoriesthat 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.cpptotestkit/takes the gate fromAll 58 rung-filter checks passedto1 of 58 rung-filter check(s) failed,naming the file. A directory carrying the suppression with no
.cppat allfails too — a scan that examines nothing reports "all Catch2" as loudly as one
that examined everything.
scripts/test_check_rung_filters.shpins all three new rejections, and nowcopies the real test sources into its scratch tree rather than the
.clang-tidyfiles alone — a synthetic corpus would go on passing after thereal tree had grown a library source in one of these directories, which is the
failure this is about. One
cp -rper directory, not per file: per-filecopying 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.hppcarriesQ_OBJECT. CMake's AUTOMOC documentation gives aheader 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.cppout oftestkit/broke the second and nothingsupplied the first, so
moc_fault_proxy.cppstopped 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.hppandtestkit/qml_surface.hpparelisted 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.
<testkit/...>include in the tree, and buys the
.clang-tidyargument nothing it does notalready have: headers are not translation units, so check 5 never looked at
them.
AUTOMOC_INCLUDE_DIRECTORIESsets the-Ilist handed tomocrather thanthe header-to-source pairing;
CMAKE_AUTOMOC_RELAXED_MODEconcerns"foo.moc"self-includes;AUTOMOC_MACRO_NAMES,AUTOMOC_DEPEND_FILTERSand
AUTOMOC_MOC_OPTIONSleave discovery alone. Checked againstcmake --help-property-listand each property's own documentation, not frommemory.
workaround for one — and it is already what
cmake/morph_add_rung.cmakedoes for every rung, with the same failure behind it. Its
_lib_headersglob exists because a rung splits
include/<rung>/app/app.hppfromsrc/app/app.cpp, and its comment namespastebin::app::Appas where thisrepository last hit this exact link error.
Both headers are listed, not only the one carrying
Q_OBJECTtoday, so thesplit 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 oldmoc_fault_proxy.cppis precisely what hides this. Without the headerentries:
— the
Linux / clang-coveragefailure, reproduced locally. With the headerentries, from a second empty directory and the same configure:
and the whole tree builds: 441 ninja edges, exit 0,
ladder_common_testsand every rung's test binary linked.
mocwrites#include "testkit/fault_proxy.hpp"and not an ascending path, because theheader sits under the target's
examples/commoninclude directory andCMAKE_AUTOMOC_PATH_PREFIXresolves it against that;check_automoc_includes.shover the tree reports 32 generated moc sources, none ascending. A third
empty directory with
-DMORPH_BUILD_BANK_EXAMPLE=ON -DMORPH_BUILD_BANK_GUI=ONlinks
bank_gui_tests, which compiles the movedqml_surface.cppby path.Check 5 is untouched by the fix and still holds. Copying both TUs back
into
examples/common/testkit/on the fixed tree:Unmutated,
All 58 rung-filter checks passed. A header in that directory isnot a
.cpp, so the scan never sees it and listing the headers as targetsources cannot weaken the condition.
Correcting the record. The earlier revision of this commit claimed
"
morph_ladder_testkitandbank_gui_testsboth link", measured against awarm 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_testkitfailed, so leaving it in history would put a broken tree under
git bisectfor anything touching
examples/. The other two commits are untouched — theirpatches are byte-identical across the rebase, checked with
diffovergit 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.mdand two stale prose references inrung tests are updated.
codecov.ymlneeds no change — its globs nametestkit/test_*.cpp, and the moved files are underexamples/commoneitherway, so they stay in the coverage denominator.
#655 —
check_spec_sync.shrefuses empty input unless asked forFirst, whether the exposure is live — measured, not read off the YAML.
CI's own invocation is not exposed.
spec-sync.yml's step runs underset -euo pipefail, assigns the diff to a variable and pipes aprintf, so afailing
git diffaborts the step. Driving that exact shape with a bad ref:The documented manual shape, in a shell with neither
-enorpipefail—an interactive one, or the pre-push hook the filer was using — is exposed
exactly as filed:
and after this change:
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-emptyrather than a hard error: "this change touches noheaders" 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_emptythat the script shruggedoff would wedge every legitimately empty invocation.
spec-sync.ymlpasses the flag only when it has observed the emptinessitself.
set -euo pipefailmeans thegit diffeither produced the list oraborted the step, so an empty
$changedthere is a real net-zero diff (a pullrequest 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'sown shape), an unrecognised argument, and
--allow-emptyover a realviolation, which must still be rejected. Rejecting empty is useless if
--allow-emptystops working; accepting it with the flag is useless if theflag 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-testchecks 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.shover this branch's real path list, and the three relevantself-tests (
test_check_spec_sync.sh,test_check_rung_filters.sh,test_check_tidy_suppression_scope.sh).clang-format --dry-run -Werrorover all four C++ files this branch touches:clean. The AUTOMOC fix touches no C++ — only
examples/common/CMakeLists.txtand
examples/TESTING.md, neither of which theclang-formatjob'sgit 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_testslinks).clang-tidy-diffrun 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, thenclang-tidy-diff.pyexit 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.04reproduces the 97 findings in #656 exactly — local clang-tidy is 22.1.8 against Qt 6.11.2, andthe runner's Qt comes from
aqtinstallatQT_VERSION; several of thosechecks 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