Skip to content

ci: build every declared MORPH_BUILD_* option, and let a red leg report every surface it measured (fixes #605, fixes #618) - #623

Merged
Yaraslaut merged 2 commits into
masterfrom
laneF-batch-605-618
Sep 20, 2026
Merged

Yaraslaut merged 2 commits into
masterfrom
laneF-batch-605-618

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

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 #605
fixes #618


Commit 1 — #605: build MORPH_BUILD_BANK_GUI, and derive the option list

(a) -DMORPH_BUILD_BANK_GUI=ON and -DMORPH_BUILD_BANK_EXAMPLE=ON added to
linux-all-features' configure step. Both are needed: the root CMakeLists only
descends into examples/bank under the latter, and examples/bank only into
gui/ under the former. That leg is the one that can host them — it already
installs 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.py plus a new option-coverage
job. It 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. The failure message says how to cover
the option; the word "exempt" does not appear in it.

The job lives in ci.yml because drift-guard.yml is held by #614. It belongs
there 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) to
CMakeLists.txt and ran the check:

::error::MORPH_BUILD_TELEMETRY is declared in this tree, defaults OFF, and no native job in .github/workflows/ passes -DMORPH_BUILD_TELEMETRY=ON -- so nothing in CI ever compiles the code it guards. Cover it: add -DMORPH_BUILD_TELEMETRY=ON to the configure step of a job whose runner already has what it needs. 'Linux / all optional features' in ci.yml is the leg meant to carry these, and already installs Qt from aqtinstall plus the ODBC/SQLite/yaml-cpp/libzip set. A CMakePresets.json preset that sets the option is not coverage -- a preset no workflow names is exactly how morph#605 happened.

1 problem(s). 17 MORPH_BUILD_* option(s) declared, 11 enabled by a workflow, 1 exempt.
exit=1

Experiment reverted before committing.

Part (a) reverted — the gate must catch the defect that motivated it.
Deleting the -DMORPH_BUILD_BANK_GUI=ON line this PR adds:

::error::MORPH_BUILD_BANK_GUI is declared in this tree, defaults OFF, and no native job in .github/workflows/ passes -DMORPH_BUILD_BANK_GUI=ON -- so nothing in CI ever compiles the code it guards. It is enabled by wasm-demo.yml, but that is an Emscripten build: the same option can select a different subtree there (examples/bank/CMakeLists.txt builds gui_wasm/ and returns), so it does not prove the native code compiles. Cover it: add -DMORPH_BUILD_BANK_GUI=ON to the configure step of a job whose runner already has what it needs. [...]

1 problem(s). 16 MORPH_BUILD_* option(s) declared, 10 enabled by a workflow, 1 exempt.
exit=1

Both mutations are now permanent cases in
scripts/test_check_workflow_option_coverage.sh — twelve cases, nine mutations
and three false-positive mirrors, each required to be caught for the stated
reason
. The option-coverage job runs the self-test before the check, the
same order deprecation-lint, test-type-name-lint and journal-stamp-lint
use:

ok: the unmodified tree passes
ok: caught: a newly declared option that no job enables
ok: caught: MORPH_BUILD_BANK_GUI dropped from ci.yml's all-features job
ok: caught: the Emscripten enablement does not count as native coverage
ok: caught: the only enablement is commented out
ok: caught: every matrix leg sets the fuzzers key OFF
ok: accepted: a matrix leg setting the key ON is the only enablement
ok: caught: an exemption for an option some job does enable
ok: caught: an exemption naming an option no CMake file declares
ok: caught: the option() declarations become unparseable
ok: accepted: a newly declared option that defaults ON
ok: accepted: an option enabled by a different job than before

all cases passed.

The self-test earned its keep during development: matrix-expression values were
never being resolved (\S+ captured the bare ${{), so the "every matrix leg
sets 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.

  1. BANK_GUI was not enabled by nothing. wasm-demo.yml:98 has passed
    -DMORPH_BUILD_BANK_GUI=ON all along; the triage's grep was scoped to
    ci.yml. The conclusion survives only because of what the flag does under
    Emscripten: examples/bank/CMakeLists.txt descends into gui_wasm/ and
    return()s before the native gui/, so it is a disjoint subtree and proves
    nothing 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.

  2. MORPH_BUILD_DOCUMENTATION is covered, not exempt. The issue put it in
    the exemption set beside CLANG_TIDY, but docs.yml:59 passes it
    literally. 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_TIDY is the only exemption.

Also: 16 declared options, not 14 — examples/vetted_hmac/CMakeLists.txt
declares two more. Both default ON, so neither changes the verdict; the check
scans every tracked CMake file so a sub-option that ever defaults OFF is
caught.

Not verified

Whether bank_gui / bank_gui_qml_tests actually compile on that leg
post-#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 each
step's pre-existing guard preserved:

job step condition
ladder-tests Run the scenario corpus against the built servers !cancelled() && steps.filter.outputs.run == 'true'
ladder-sanitizers Test (the Qt transport suites, which no sanitizer leg ran) !cancelled() && steps.filter.outputs.run == 'true'
linux-all-features Verify the fuzz replay covered the committed reproducers !cancelled() && matrix.fuzzers == 'ON'

linux-coverage is deliberately unchanged, and now carries a comment saying
so. Its later steps consume the Test step's .profraw output rather than
measuring 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(), 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.

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 steps
also 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' would
reintroduce 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 so
plainly rather than claiming an unmeasured success, as the triage comment
invites.

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:

== ladder-tests
   Run the scenario corpus against the built servers -> !cancelled() && steps.filter.outputs.run == 'true'
== ladder-sanitizers
   Test (the Qt transport suites, which no sanitizer leg ran) -> !cancelled() && steps.filter.outputs.run == 'true'
== linux-all-features
   Verify the fuzz replay covered the committed reproducers -> !cancelled() && matrix.fuzzers == 'ON'
== linux-coverage

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

Filed while working this

🤖 Generated with Claude Code

https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk

Yaraslaut and others added 2 commits September 20, 2026 10:34
…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

codecov Bot commented Sep 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Yaraslaut
Yaraslaut merged commit 4cde026 into master Sep 20, 2026
49 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant