ci: build every declared MORPH_BUILD_* option, and let a red leg report every surface it measured (fixes #605, fixes #618) - #623
Merged
Conversation
…n assert it (fixes #605) `MORPH_BUILD_BANK_GUI` defaults OFF and was set ON only by the `linux-everything` preset in CMakePresets.json, which no workflow names. The native `bank_gui` / `bank_gui_qml_tests` targets were therefore compiled by no job, which is how #604 happened: a target went unbuildable on master with every gate green, and the job called "Linux / all optional features" went on passing, because its option list is hand-written and nothing compared it to the options that exist. Two parts. (a) Add `-DMORPH_BUILD_BANK_GUI=ON` and `-DMORPH_BUILD_BANK_EXAMPLE=ON` (which it needs -- the root CMakeLists only descends into examples/bank under the latter, and examples/bank only into gui/ under the former) to the `linux-all-features` configure step. That is the one leg that can host them: it already installs Qt 6.8.1 from aqtinstall, which the GUI's Qml/Quick/QuickControls2 at 6.5+ requires and the distro Qt the other bank jobs use cannot give, and already installs the ODBC/SQLite/yaml-cpp/libzip set the bank example's Lightweight fetch needs. (b) A gate that derives the list. Adding the flag closes the hole once; it does not close the class, and AGENTS.md's own test -- "ask whether the check would still pass if the feature did nothing" -- answers yes for (a) alone. Nothing stops option 17 from being declared next month and enabled by nobody. scripts/check_workflow_option_coverage.py enumerates every `option(MORPH_BUILD_...)` in the tracked CMake files and every `-DMORPH_BUILD_*=ON` in .github/workflows/, and fails when a declared option is built by nothing. Covered means: declared default ON, or enabled by a native workflow, or enabled through a matrix key some leg sets ON, or exempt with a written reason. Its failure message says how to cover the option, not how to exempt it. Two corrections to the issue's accounting, both found by writing the check: - `MORPH_BUILD_DOCUMENTATION` is *not* exempt. The issue put it in the exemption set beside `CLANG_TIDY`, but docs.yml's configure step passes `-DMORPH_BUILD_DOCUMENTATION=ON` literally, so it is covered by derivation. Exempting it would have been exactly the hand-maintained falsehood this gate exists to prevent, so the checker also rejects an exemption for an option that is in fact covered, and one naming an option no CMake file declares. `MORPH_BUILD_CLANG_TIDY` is the only exemption. - `MORPH_BUILD_BANK_GUI` was not enabled by *nothing*: wasm-demo.yml has passed `-DMORPH_BUILD_BANK_GUI=ON` all along. Under EMSCRIPTEN, examples/bank/CMakeLists.txt descends into gui_wasm/ and returns before the native gui/ exists, so the same option name selects a disjoint subtree and that build proves nothing about the target #604 found broken. Counting it would have made this gate vacuous for the one option it was written for, so Emscripten workflows are classified apart -- derived from the workflow text (`emcmake`/`emsdk`/`qt-cmake`), not from a list of filenames. The checker is not trusted on its own report: scripts/test_check_workflow_- option_coverage.sh reintroduces each drift it claims to catch into a scratch copy of the tree, one at a time, and requires it to be caught for the stated reason -- including dropping (a)'s own flag, which must go red despite wasm-demo.yml. Twelve cases, nine of them mutations and three false-positive mirrors. The `option-coverage` job runs the self-test before the check, the same order the deprecation, test-type-name and journal-stamp lints use. Its home is drift-guard.yml once #614 lands; that file is held, so it is here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk
Three jobs run disjoint test activities in sequence with no condition on the later steps, so the implicit `success()` means a failure in the first stops the second from running at all. The surface the second covers is then measured nowhere, and nothing in the log distinguishes "fine" from "never attempted" -- discovering it costs a full 26-74 minute cycle on the next push, including every green leg that had nothing to do with it. `!cancelled()` on the later step in each: - ladder-tests: "Run the scenario corpus against the built servers", a different surface from the ctest step above it. - ladder-sanitizers: "Test (the Qt transport suites, which no sanitizer leg ran)". This is the case that settles the argument -- by that step's own comment it is the only leg in CI that runs the Qt transport suites under any sanitizer. - linux-all-features: "Verify the fuzz replay covered the committed reproducers", which checks whether a guard went vacuous and cannot be inferred from the ctest step's result. `!cancelled()`, not `always()`. `always()` also fires during teardown of a cancelled run, which makes a leg that stopped within seconds of its siblings read as a defect rather than a cancellation -- the artefact this repository's own triage guidance warns about. `!cancelled()` runs on success or failure and stops on cancellation. Each condition keeps the guard that was already there (`steps.filter.outputs.run`, `matrix.fuzzers`), so nothing newly runs on a leg that was being skipped for its own reasons. Neither condition changes a job's conclusion: a red step keeps the job red. This makes a failing leg report more, not gate less. linux-coverage is deliberately not changed, and now says so in a comment next to the steps in question. Its "Generate coverage report" and the two steps after it consume the Test step's `.profraw` output rather than measuring a surface of their own; forcing them to run over truncated profile data would produce a coverage number computed from a partial pass, and a number nobody should trust is worse than no number. Written down because it is the part someone would otherwise "fix" later. Verification status: INFERRED, NOT REPRODUCED. The acceptance the issue asks for -- deliberately fail the first test step of one of these jobs and observe the later one still run and still report, on a red job -- requires a CI cycle against a deliberately broken branch, which this lane cannot run. What was measured: the edited ci.yml parses, and each of the three steps carries exactly `!cancelled() && <its pre-existing guard>` while linux-coverage carries none. The behavioural claim rests on GitHub Actions' documented status-check-function semantics, the same standard as the issue's and the triage's own premise check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Yaraslaut
added a commit
that referenced
this pull request
Sep 20, 2026
…, and clear clang-tidy batch A (fixes #627, fixes #600) (#631) * lint: make four NOLINTNEXTLINE directives apply, and gate the ones that cannot (fixes #627) A NOLINTNEXTLINE annotates the next *physical* line. Four directives in the tree had their reason wrapped onto a second comment line, so each annotated that comment instead of the code, and clang-tidy reported nothing about it -- the directive parses, the file looks annotated, and the findings leak. Measured on 0e3b882, clang-tidy 22.1.8, with the CI clang-tidy job's own option set. Before, the four leaked six findings: forms.hpp:489:39 forwarding reference parameter 'action' is never forwarded forms.hpp:489:57 forwarding reference parameter 'visitor' is never forwarded forms.hpp:496:41 possibly unsafe 'operator[]' forms.hpp:496:66 possibly unsafe 'operator[]' oom_injector.cpp:110:21 do not manage memory manually oom_injector.cpp:110:9 initializing non-owner with a newly created owner After, all six are gone. The fourth directive, test_bridge_lifetime.cpp:519, was inert *and* unnecessary -- cppcoreguidelines-owning-memory does not fire on placement new, confirmed by measurement -- so it is now merely effective, and kept so a later edit to that line cannot reintroduce the finding silently. Each site moves its reason above the directive rather than adding a clang-format guard, so the fix survives reformatting. forms.hpp:487's directive also dropped an unchecked-container-access it never needed; only missing-std-forward fires on that line, and the reason now says why neither parameter may be forwarded rather than restating the check's name. The guard is the part that matters. scripts/check_nolint_directives.sh fails when a NOLINTNEXTLINE is followed by a comment, a blank line, or nothing at all. Run against unmodified master it reports exactly the four sites above, at exactly those line numbers, and it does not flag fixed_string.hpp:48 -- the prose that documents this hazard and whose existence is why the scan anchors the directive at the start of the comment. That anchoring is a stated residual, not an oversight. scripts/test_check_nolint_directives.sh drives the checker against tests/lint/nolint_directives/: the two effective shapes must be accepted, each inert shape rejected on its own while naming its own file, and a directory with no directives at all rejected rather than called clean. A gate for suppressions that suppress nothing would be the same defect one level up if it were not itself tested. The job belongs in drift-guard.yml and is in its own workflow only because that file and ci.yml are both held by open PRs (#614, #623); the workflow's header says so and folding it in changes nothing about its behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk * lint: clear clang-tidy batch A, and set the campaign's suppression precedents (fixes #600) #580's census of 596 findings splits into six batches by file tree. Batch A is the tail -- version/attributes/journal/render/qt-forms/detail -- and it is first not because it is smallest but because it is the only batch that forces every policy precedent the other five need, on a corpus where getting one wrong is cheap. `include/morph/render/locale_format.hpp` is excluded: PR #630 rewrites it and changes its answer, so its one finding must be re-measured, not inherited. Measured on 0e3b882, clang-tidy 22.1.8, .clang-tidy unmodified, with the CI clang-tidy job's own configure. Nine TUs -- the VIHS stub for each batch-A header plus quantity.hpp's stub, examples/forms/gui_qml/FormsController.cpp and tests/test_quantity.cpp for the findings a standalone header stub cannot reach because it instantiates nothing. Before, deduplicated by path+line+column, that reproduces the census row for row: 7 include/morph/version.hpp 4 include/morph/journal/action_log.hpp 4 include/morph/qt/forms/forms_controller_core.hpp 2 include/morph/journal/file_action_log.hpp 1 include/morph/attributes.hpp 1 include/morph/detail/quantity_equation.hpp 1 include/morph/render/i18n.hpp -- 20 After: 0. The same run still reports 261 findings elsewhere under include/morph/, so it analysed the tree rather than failing to. Fixed rather than suppressed, four checks: readability-use-concise-preprocessor-directives (1) -- attributes.hpp's `#if defined(__has_cpp_attribute)` is now `#ifdef`. This is #600's finding. readability-redundant-member-init (4) -- action_log.hpp's `std::string x{}` members drop the initializer. std::string's default constructor is non-trivial, so aggregate and default initialization are unchanged. readability-identifier-length (1) -- file_action_log.hpp's `std::ifstream in` becomes `input`. performance-unnecessary-value-param (4) -- forms_controller_core.hpp's submitIfValid/fetchOptions took `std::string` by value and passed it to executeJson, whose parameters are `std::string_view`. Neither was ever moved, so the copies bought nothing; both are now `const std::string&`, which is source-compatible. examples/bookmarks' mirror moves with it -- its own doc comment asserts it has the same body as this one, and that claim has to stay true. cppcoreguidelines-pro-bounds-avoid-unchecked-container-access (1) -- file_action_log.hpp's `lines[i]` becomes `lines.at(i)`, hoisted out of the try/catch that surrounds it. The loop condition already bounds `i`, so the check cannot fire; if it ever could, std::out_of_range inside that try would be caught and mis-reported as a malformed journal line. Suppressed with a reason, three checks -- the precedents the remaining batches inherit, written to the standard set by render/locale_format.hpp:181: macro-to-enum + macro-usage (7) -- version.hpp's macros are the `#if`-testable half of the version API. An enumerator is invisible to the preprocessor and a constexpr function cannot be called from a `#if`, so the checks do not propose a different spelling, they propose removing the capability. Unfixable by construction; the constants the checks ask for already exist beside them, defined from the macros so the two cannot drift. bugprone-easily-swappable-parameters (1) -- render/i18n.hpp's resolveText. The check is right that swapping derivedKey and schemaLiteral would be silent. They stay because the order is the documented resolution chain, mirrored parameter-for-parameter by DynamicForm.qml's resolveText. misc-header-include-cycle (1) -- quantity_equation.hpp's include back into quantity.hpp. The cycle is real as a graph statement and deliberate as a design: it is closed by `#pragma once` and it is what makes the header analysable standalone. The remedy the check proposes is the state this file was moved away from. No bare NOLINT anywhere: each suppression says why the check is wrong at that site, not what the check is called. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two CI tickets, one commit each, disjoint edits to
.github/workflows/ci.yml.Either commit can be dropped and force-pushed without redoing the other.
fixes #605fixes #618Commit 1 — #605: build
MORPH_BUILD_BANK_GUI, and derive the option list(a)
-DMORPH_BUILD_BANK_GUI=ONand-DMORPH_BUILD_BANK_EXAMPLE=ONadded tolinux-all-features' configure step. Both are needed: the root CMakeLists onlydescends into
examples/bankunder the latter, andexamples/bankonly intogui/under the former. That leg is the one that can host them — it alreadyinstalls Qt 6.8.1 from aqtinstall (the GUI needs Qml/Quick/QuickControls2 at
6.5+) and already installs the ODBC/SQLite/yaml-cpp/libzip set the bank
example's Lightweight fetch requires.
(b)
scripts/check_workflow_option_coverage.pyplus a newoption-coveragejob. It enumerates every
option(MORPH_BUILD_...)in the tracked CMake filesand every
-DMORPH_BUILD_*=ONin.github/workflows/, and fails when adeclared option is built by nothing. Covered means: declared default
ON, orenabled by a native workflow, or enabled through a matrix key some leg sets
ON, or exempt with a written reason. The failure message says how to coverthe option; the word "exempt" does not appear in it.
The job lives in
ci.ymlbecausedrift-guard.ymlis held by #614. It belongsthere once that lands.
Non-vacuity — measured, not asserted
Two mutations, real output.
A declared option that no job enables. Added
option(MORPH_BUILD_TELEMETRY "Build the telemetry sink (experiment)" OFF)toCMakeLists.txtand ran the check:Experiment reverted before committing.
Part (a) reverted — the gate must catch the defect that motivated it.
Deleting the
-DMORPH_BUILD_BANK_GUI=ONline this PR adds:Both mutations are now permanent cases in
scripts/test_check_workflow_option_coverage.sh— twelve cases, nine mutationsand three false-positive mirrors, each required to be caught for the stated
reason. The
option-coveragejob runs the self-test before the check, thesame order
deprecation-lint,test-type-name-lintandjournal-stamp-lintuse:
The self-test earned its keep during development: matrix-expression values were
never being resolved (
\S+captured the bare${{), so the "every matrix legsets the fuzzers key OFF" case passed for the wrong reason until its
accepted-mirror was added and exposed it.
Two corrections to #605's accounting
Both found by writing the derived check, both recorded on the
issue
rather than shipped around.
BANK_GUIwas not enabled by nothing.wasm-demo.yml:98has passed-DMORPH_BUILD_BANK_GUI=ONall along; the triage's grep was scoped toci.yml. The conclusion survives only because of what the flag does underEmscripten:
examples/bank/CMakeLists.txtdescends intogui_wasm/andreturn()s before the nativegui/, so it is a disjoint subtree and provesnothing about the target build: bank_gui_qml_tests does not compile -- testkit_main.cpp's <testkit/log_level.hpp> is not on its include path #604 found broken. Counting it would have made this
gate vacuous for its own founding case, so Emscripten workflows are
classified apart — derived from the workflow text (
emcmake/emsdk/qt-cmake), not a filename list, and pinned by a self-test case.MORPH_BUILD_DOCUMENTATIONis covered, not exempt. The issue put it inthe exemption set beside
CLANG_TIDY, butdocs.yml:59passes itliterally. Exempting it would be exactly the hand-maintained falsehood the
gate replaces, so the checker also rejects an exemption for an option that
is covered, and one naming an option no CMake file declares.
MORPH_BUILD_CLANG_TIDYis the only exemption.Also: 16 declared options, not 14 —
examples/vetted_hmac/CMakeLists.txtdeclares two more. Both default
ON, so neither changes the verdict; the checkscans every tracked CMake file so a sub-option that ever defaults
OFFiscaught.
Not verified
Whether
bank_gui/bank_gui_qml_testsactually compile on that legpost-#604. Not built locally (Qt 6.8 via aqtinstall plus the Lightweight/ODBC
fetch is not reproducible here); this PR's CI run is the first measurement.
If it fails, that is a new issue, not a fold-in.
Commit 2 — #618: a red leg reports every test surface it could measure
if: '!cancelled()'on the later, disjoint test step in three jobs, with eachstep's pre-existing guard preserved:
ladder-tests!cancelled() && steps.filter.outputs.run == 'true'ladder-sanitizers!cancelled() && steps.filter.outputs.run == 'true'linux-all-features!cancelled() && matrix.fuzzers == 'ON'linux-coverageis deliberately unchanged, and now carries a comment sayingso. Its later steps consume the Test step's
.profrawoutput rather thanmeasuring a surface of their own; forcing them to run over a truncated profile
would produce a coverage number computed from a partial pass, and a number
nobody should trust is worse than no number. Written down because it is the part
someone would otherwise "fix" later.
!cancelled(), notalways().always()also fires during teardown of acancelled run, which makes a leg that stopped within seconds of its siblings
read as a defect rather than a cancellation — the artefact this repository's own
triage guidance warns about.
This does not weaken a gate. Neither condition changes a job's conclusion: a
red step keeps the job red. The change makes a failing leg report more, not
gate less.
Known consequence, accepted: with the implicit
success()gone, these stepsalso run when the Build step failed, where they will fail loudly against a
missing or partial build tree. That is noise on a job already going red, and
narrowing the condition to
steps.<build>.outcome == 'success'wouldreintroduce a hand-maintained coupling to a step id. Flagged rather than
silently traded.
Verification status: INFERRED, NOT REPRODUCED
The acceptance #618 asks for — deliberately fail the first test step of one of
these jobs and observe the later one still run and still report, on a red job —
needs a CI cycle against a deliberately broken branch. This lane's brief ends at
the push and forbids waiting on or polling CI, and no local Actions runner
(
act) is available, so the demonstration was not performed. Saying soplainly rather than claiming an unmeasured success, as the triage comment
invites.
What was measured: the edited
ci.ymlparses, and each of the three stepscarries exactly
!cancelled() && <its pre-existing guard>whilelinux-coveragecarries none:The behavioural claim rests on GitHub Actions' documented status-check-function
semantics — the same standard as #618's own premise check and the triage's,
both of which recorded it as inferred.
Coordination
drift-guard.yml,docs/spec/testing_charter.md), cmake: let a red leg name every failing test, not just the first (fixes #616) #617(
CMakePresets.json), qt: move QtWebSocketBackend onto the structural registration surface #585, render/forms: match the QML mirror's separators as whole strings (fixes #599) #609 and fix(util): handle INT64_MIN rational operations #561: untouched. The testing-charter rowA red CI job reports only its first failing test *step*, hiding the rest (the step-level twin of #616) #618 asks for, and this gate's move into
drift-guard.yml, both wait on ci: audit mutation_survivors.json's line citations instead of hoping (fixes #608) #614.0e3b8823(after render/forms: match the QML mirror's separators as whole strings (fixes #599) #609 landed). cmake: let a red leg name every failing test, not just the first (fixes #616) #617 had not landed at push time;it removes
stopOnFailurefrom the test presets, which is the case-level twinof this step-level change and does not conflict with it.
CHANGELOG.mdentry, matching cmake: let a red leg name every failing test, not just the first (fixes #616) #617's precedent for a change of this shape.Filed while working this
ci.yml's seventeen job banner comments sit above theprevious job's trailing cache steps, so each banner's multi-paragraph
rationale is separated from the job it describes by 6–31 lines of another
job's steps. Reproduced on
a020e69c, readability only, unrelated to thesechanges.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk