Skip to content

lint: make wrapped NOLINT directives apply, gate the ones that cannot, and clear clang-tidy batch A (fixes #627, fixes #600) - #631

Merged
Yaraslaut merged 2 commits into
masterfrom
laneH-batch-627-580A
Sep 20, 2026
Merged

Yaraslaut merged 2 commits into
masterfrom
laneH-batch-627-580A

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Two tickets, one commit each, in this order deliberately: batch A's remedy is
partly "write an individually reasoned NOLINT", and that cannot proceed
honestly on a tree where NOLINTs silently fail to apply.

fixes #627
fixes #600


1. Four suppressions that suppressed nothing, and a gate so it cannot recur

NOLINTNEXTLINE annotates the next physical line. Four directives had their
reason wrapped onto a second comment line, so each annotated that comment. The
directive parses, the file looks annotated, clang-tidy says nothing, and the
findings are still reported.

The four, fixed

site what leaked
include/morph/forms/forms.hpp:487 cppcoreguidelines-missing-std-forward ×2
include/morph/forms/forms.hpp:494 cppcoreguidelines-pro-bounds-avoid-unchecked-container-access ×2
tests/oom_injector.cpp:107 cppcoreguidelines-no-malloc, cppcoreguidelines-owning-memory
tests/test_bridge_lifetime.cpp:519 nothing — inert and unnecessary

Each moves its reason above the directive rather than adding a
clang-format off guard, so the fix survives reformatting. fixed_string.hpp:48
is untouched: it is the prose documenting this hazard, and its clang-format
guard is the in-tree worked example of the other remedy.

Two judgement calls, stated rather than buried:

  • forms.hpp:487 dropped a check it never needed. Only missing-std-forward
    fires on that line. Four NOLINTNEXTLINE directives are wrapped onto two lines and suppress nothing; six findings leak past them #627 flagged that the finding "may be a real defect (a
    forwarding reference that is never forwarded) rather than a false positive the
    author correctly waved off", and said someone should decide before restoring
    the suppression. Decided: neither parameter may be forwarded. action is bound
    by glz::to_tie into a tuple of references read member-by-member afterwards, so
    moving from it would leave the tie pointing at a moved-from object; visitor is
    invoked once per reflected member by the fold expression, so forwarding it would
    move from it on the first member and call a moved-from callable for every one
    after. The && is there to carry cv-qualification through the tie. That reasoning
    is now the comment.
  • test_bridge_lifetime.cpp:519 was both broken and unnecessary, as Four NOLINTNEXTLINE directives are wrapped onto two lines and suppress nothing; six findings leak past them #627
    suspected but could not confirm. Measured: cppcoreguidelines-owning-memory does
    not fire on placement new. Kept, now effective, so a later edit to that line
    cannot reintroduce the finding silently. (The owning-memory finding that does
    exist in that file is at :448, an unrelated delete.)

Before / after, measured

0e3b8823, clang-tidy 22.1.8, .clang-tidy unmodified, CI clang-tidy job's own
configure. Before — master's files, the leaked findings present:

include/morph/forms/forms.hpp:489:39: error: forwarding reference parameter 'action' is never forwarded inside the function body [cppcoreguidelines-missing-std-forward,-warnings-as-errors]
include/morph/forms/forms.hpp:489:57: error: forwarding reference parameter 'visitor' is never forwarded inside the function body [cppcoreguidelines-missing-std-forward,-warnings-as-errors]
include/morph/forms/forms.hpp:496:41: error: possibly unsafe 'operator[]' [cppcoreguidelines-pro-bounds-avoid-unchecked-container-access]
include/morph/forms/forms.hpp:496:66: error: possibly unsafe 'operator[]' [cppcoreguidelines-pro-bounds-avoid-unchecked-container-access]
tests/oom_injector.cpp:110:21: error: do not manage memory manually; consider a container or a smart pointer [cppcoreguidelines-no-malloc,-warnings-as-errors]
tests/oom_injector.cpp:110:9: error: initializing non-owner 'void *' with a newly created 'gsl::owner<>' [cppcoreguidelines-owning-memory,-warnings-as-errors]

After — all six gone; grep -E "missing-std-forward|no-malloc" over the same
logs returns nothing, and forms.hpp:49x is clean.

The gate, and its non-vacuity

scripts/check_nolint_directives.sh fails when a NOLINTNEXTLINE is followed by
a comment, a blank line, or nothing at all.

The strongest evidence it measures something is that it reproduces #627's own
finding from scratch.
Run against the four files as they are on unmodified
origin/master, plus fixed_string.hpp:

$ bash scripts/check_nolint_directives.sh <master's include> <master's tests>

.../include/morph/forms/forms.hpp:487: NOLINTNEXTLINE is followed by a comment or blank line, so it suppresses nothing
    487 | // NOLINTNEXTLINE(cppcoreguidelines-missing-std-forward, cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
    488 | // — member-tie iteration
.../include/morph/forms/forms.hpp:494: NOLINTNEXTLINE is followed by a comment or blank line, so it suppresses nothing
    494 |         // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) — index bounded by
    495 |         // reflect::size
.../tests/oom_injector.cpp:107: NOLINTNEXTLINE is followed by a comment or blank line, so it suppresses nothing
    107 |     // NOLINTNEXTLINE(cppcoreguidelines-no-malloc, cppcoreguidelines-owning-memory) --
    108 |     // this *is* the process-wide operator new/delete pair; std::malloc/free
.../tests/test_bridge_lifetime.cpp:519: NOLINTNEXTLINE is followed by a comment or blank line, so it suppresses nothing
    519 |     // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) -- placement new into
    520 |     // the mmap'd region above; destroyed via an explicit dtor call below, not
exit=1

Exactly four, at exactly the line numbers the issue names, and not
fixed_string.hpp:48 — which is in that same fixture set and is the prose
warning about this hazard, not a directive.

And the mutation test asked for: re-wrap one of the four on the fixed tree.

$ bash scripts/check_nolint_directives.sh

include/morph/forms/forms.hpp:496: NOLINTNEXTLINE is followed by a comment or blank line, so it suppresses nothing
    496 | // NOLINTNEXTLINE(cppcoreguidelines-missing-std-forward) -- the tie binds by
    497 | // reference and the visitor is called once per member
...
Scanned 165 NOLINTNEXTLINE directive(s) under: include src tests examples
exit=1

Reverted:

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

(166 after batch A adds one.)

scripts/test_check_nolint_directives.sh makes that permanent, in the shape
every other lint here uses — self-test first, then the gate. Fixtures in
tests/lint/nolint_directives/: both effective shapes accepted, each inert shape
(wrapped reason, blank line, last line of file) rejected on its own and naming
its own file
, and a directory with no directives at all rejected rather than
called clean.

ok: valid fixtures accepted
ok: invalid fixture blank_line rejected
ok: invalid fixture last_line rejected
ok: invalid fixture wrapped_reason rejected
ok: directory with no directives rejected
all NOLINT-directive checker self-tests passed

Where the step lives, and why not where it belongs

It belongs in .github/workflows/drift-guard.yml. Both candidate homes are
held right now
: #614 holds drift-guard.yml, #623 holds ci.yml, and both are
still open as of this push. So it is a new workflow,
.github/workflows/suppression-guard.yml, whose header says exactly that and
says folding it into drift-guard.yml changes nothing about its behaviour.
Tracked separately rather than left as a comment.

Stated residual

The scan anchors on NOLINTNEXTLINE as the first token after the comment
marker
. clang-tidy itself is looser — it scans for the literal anywhere in a
comment — so // A NOLINTNEXTLINE directive must sit on ONE physical line... is,
to clang-tidy, a (vacuous, harmless) directive. Anchoring is deliberate: without
it, the in-tree documentation of this hazard could not be written. The residual
is a directive hidden mid-sentence; no such site exists in the tree. This is in
the script's header, not only here.


2. clang-tidy batch A: 20 → 0, and the precedents the other five batches inherit

Per #580's census and the owner's decision on it:
cppcoreguidelines-pro-bounds-avoid-unchecked-container-access stays enabled and
its findings get fixed — .at() or an individually reasoned NOLINT. A bare
NOLINT appears nowhere in this PR.

Batch A is 21 findings across 8 files; include/morph/render/locale_format.hpp
is excluded because PR #630 rewrites it and changes its answer, so its one
finding must be re-measured rather than inherited. That leaves 20 across 7
files
.

Measurement

0e3b8823, clang-tidy 22.1.8, .clang-tidy unmodified, configured with the
clang-tidy job's own option set (ci.yml:2191). Nine TUs: the
verify_interface_header_sets 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. Deduplicated by path + line + column, as the
census did.

Before — the census row for row, with no adjustment:

TOTAL distinct findings: 20
    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/render/i18n.hpp
    1  include/morph/detail/quantity_equation.hpp

After:

TOTAL distinct findings: 0

That zero is not a compile failure wearing a disguise. The same nine logs
still report 261 distinct findings elsewhere under include/morph/
(forms.hpp 104, quantity.hpp 46, views.hpp 40, …), no TU emitted a
clang-diagnostic-error, and every target below built and ran.

check → remedy → why

check n remedy why
readability-use-concise-preprocessor-directives 1 fix attributes.hpp's #if defined(__has_cpp_attribute)#ifdef. Lexical; this is #600's finding.
readability-redundant-member-init 4 fix action_log.hpp's std::string x{} members drop the initializer. std::string's default ctor is non-trivial, so aggregate and default initialization are unchanged either way.
readability-identifier-length 1 fix file_action_log.hpp's std::ifstream ininput. Nothing about in was the vocabulary of an API, which is the only case where these renames lose.
performance-unnecessary-value-param 4 fix forms_controller_core.hpp's submitIfValid/fetchOptions took std::string by value and passed it to executeJson, whose parameters are std::string_view. Never moved, so the copies bought nothing. const std::string&, not string_view: source-compatible, and it does not hand a view of a caller's temporary to an async dispatch path.
cppcoreguidelines-pro-bounds-avoid-unchecked-container-access 1 fix (.at()) file_action_log.hpp's lines[i]. Not a hot path — one journal read. Hoisted out of the surrounding try: the loop already bounds i so it cannot throw, but a .at() inside that try would have its std::out_of_range caught by catch (const std::exception&) and mis-reported as a malformed journal line.
cppcoreguidelines-macro-to-enum + modernize-macro-to-enum + cppcoreguidelines-macro-usage 7 reasoned suppression (NOLINTBEGIN/END) Unfixable by construction. version.hpp's macros exist to be #if-testable: #if MORPH_VERSION >= MORPH_MAKE_VERSION(1, 2, 0) must be answerable by the preprocessor, where an enumerator is invisible and a constexpr function cannot be called. The checks do not propose a different spelling of the header; they propose removing the capability it exists to provide. The constants they ask for already exist beside them (morph::version::kMajor…), defined from the macros so the two cannot drift.
bugprone-easily-swappable-parameters 1 reasoned suppression render/i18n.hpp's resolveText. The check is right that swapping derivedKey and schemaLiteral would be silent — the key would be rendered as display text and the literal looked up as a key. They stay because the order is the contract: the three parameters are the resolution chain in precedence order, as the @brief states it, as the body tries them, as docs/spec/forms/forms.md specifies it, and as DynamicForm.qml's resolveText(explicitKey, derivedKey, literal) mirrors it parameter for parameter. Reordering would desynchronise that pair. A TranslationKey strong type would remove the hazard outright, and is a design change to the renderer seam, not a lint fix — noted in the comment as the condition for deleting the suppression.
misc-header-include-cycle 1 reasoned suppression quantity_equation.hpp's include back into quantity.hpp. The cycle is real as a graph statement and deliberate as a design — closed by #pragma once, and it is what makes the header analysable standalone (the file already carries a paragraph saying so). The check's remedy, break the edge, is the state this file was deliberately moved away from.

The precedent, stated once: prefer .at(); suppress only where the check is
wrong at that site, and say why in a paragraph that would survive someone
disagreeing with it. The bar is include/morph/render/locale_format.hpp:181.
Restating the check's name is not a reason.

One thing that moved outside include/morph/

examples/bookmarks/gui_lib/bookmark_forms_controller.hpp's submitIfValid
takes the same signature change. Its own doc comment says "Same body as
FormsControllerCore::submitIfValid", and that claim has to stay true. Not a
separate finding — the same declaration, duplicated.

What was built and run

cmake --build build/clang-debug --target all_verify_interface_header_sets \
      morph_forms_controller_core_tests ladder_bookmarks_gui_lib   ->  exit 0
cmake --build build/clang-debug --target morph_tests               ->  exit 0

./tests/morph_tests
  test cases:  1539 |  1538 passed | 1 failed as expected
  assertions: 22648 | 22647 passed | 1 failed as expected

./src/qt/forms/morph_forms_controller_core_tests
  All tests passed (9 assertions in 5 test cases)

Plus clang-format --dry-run --Werror clean on every touched file, and
check_spec_citations.sh, check_catch_test_names.sh, check_rung_filters.sh,
check_deprecated_markers.sh, check_ci_clang_pin.sh all green.


Review notes

Done inline rather than via /code-review.

  • The .at() hoist is the only behaviour-adjacent change in the PR, and it is
    a strict improvement: it moves a call that cannot throw out of a catch that
    would have mishandled it if it could.
  • const std::string& is an API change on a public template. It is
    source-compatible in both directions (submitIfValid(std::move(s), …) still
    compiles) and the spec cites these by parameter name, not type, so no spec
    text moves.
  • Removing {} from four members is the one change where "mechanical" could
    hide a semantic difference. It does not: the members are std::string, whose
    default constructor is non-trivial, so aggregate init, value init and default
    init all produce an empty string with or without the initializer.
  • The guard is the piece most likely to be wrong in an invisible way, which is
    why it gets a self-test, a vacuity case, a reproduction of the original four,
    and a mutation test, rather than "it passes".

What I did not verify

  • Not run in CI. Every number here is from one Arch Linux machine,
    clang/clang-tidy 22.1.8, 12 cores. CI pins CLANG_VERSION: "22" and a
    different libstdc++; the integers may differ, the shape should not.
  • Not a whole-tree clang-tidy sweep. Batch A's 20 → 0 is measured over the
    nine TUs that reach batch A's headers, not over all 695. A finding in one of
    these seven headers reachable only from some other include closure would not
    appear here. The before-run reproducing the census's per-file counts exactly is
    the evidence that the TU set is adequate, not a proof.
  • readability-identifier-length on in was fixed by renaming without
    checking whether .clang-tidy's IgnoredVariableNames would have been the
    better answer for the batch-B sha1.hpp cases. That decision is deferred to
    batch B, where 26 of them live, and is not prejudged here.
  • No --fix was run. Every edit is by hand.
  • locale_format.hpp is not measured at all, deliberately; render/forms: escape the bidi controls in the QML i18n tests, and support native digit sets at both control edges (fixes #610, fixes #591) #630 owns it.
  • The Qt/QML surfaces were not run, only compiled — morph_forms_controller_core_tests
    is the C++ half; the QML tests need a display.
  • I did not check what the new workflow does to total CI wall time. It is one
    ubuntu-24.04 job running two bash scripts over ~700 files; measured locally at
    well under a second, but that is a local number.

Filed separately

Per AGENTS.md, findings from this work that are not this change:

Both linked below once opened.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk

Yaraslaut and others added 2 commits September 20, 2026 11:35
…at 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
…ecedents (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
@Yaraslaut

Copy link
Copy Markdown
Member Author

Follow-up issues filed from this work, per AGENTS.md:

Also recorded on #580: include/morph/render/locale_format.hpp is excluded from batch A here, so batch A is 20 findings, not 21. Its row must be re-measured after #630 lands rather than reused.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk

@Yaraslaut
Yaraslaut merged commit 6b6df1a into master Sep 20, 2026
28 of 31 checks passed
@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 added a commit that referenced this pull request Sep 20, 2026
…old the NOLINT gate into drift-guard (fixes #613, fixes #633) (#635)

* ci: split mutation_survivors.json's citations by what they are (fixes #613)

The file carried two kinds of `line` with opposite semantics and nothing
told them apart. #613's decision comment measured the split: 11 citation
occurrences (8 distinct sites) under `classes` assert something about
*current* code, and 20 sit in dated campaign records that describe the
tree as it was when the campaign ran.

Live half -> the structured shape. The two `representative_sites` lists
and the boundary-comparison shape under `unasserted` now carry
{file, line, source, reason} entries. scripts/check_mutation_survivors.py
picks them up with NO change to it -- find_entries() walks the whole
document by design -- and goes from 7 audited citations to 15 while its
free-text count drops 31 -> 20. Six of the eight had drifted; the two
backend.hpp metric sites have identical twins elsewhere in the file, so
each entry records that a future drift will be reported as ambiguous
rather than corrected, which is the right answer for them.

Historical half -> pinned, not stripped. A line number plus a revision is
a working pointer forever, so each dated section gains a `revision` and a
`revision_provenance` that states how well it is established rather than
asserting it:

  runs[0]                   92366f6  INFERRED (parent of the commit that
                                      recorded it; no citations in it)
  runs[1]                   d5c455f  VERIFIED (both citations resolve)
  runs[2]                   adfe8e5  VERIFIED (gh run view 34349442137
                                      headSha; the run id was never a
                                      revision, which #613 assumed it was)
  false_positive_finding    d5c455f  VERIFIED for the four header
                                      citations, partial for the tests/ ones
  mechanism_confirmed       567168b  PARTIAL, and says so: that campaign
                                      measured standalone programs, not
                                      this tree
  classification_2026_09_09 adfe8e5  VERIFIED (all 11 citations re-resolved)

The gate still does not audit the historical half and still prints how
many it left alone, so a green tick cannot read as "all citations
checked". `_comment` now states which sections are which, and that
refreshing a historical line number falsifies the record.

The only change to check_mutation_survivors.py is inside its module
docstring, which claimed 31 free-text citations and called them rot.

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

* ci: fold the NOLINT-directive gate into drift-guard.yml (fixes #633)

#631 put it in a workflow of its own only because drift-guard.yml was
held by the then-open #614. #614 has landed, so the reason is gone.

The two steps move unaltered onto prose-lint, alongside the
spec-citation, CI-clang-pin and Catch2-name scans, which are the same
shape: fast, dependency-free text scans that compile nothing. The
"WHY THIS IS ITS OWN WORKFLOW" paragraph is dropped and the rest of the
header comment -- the hazard, and the deliberate anchoring residual --
comes with the steps.

The check name changes from "Suppression guard / NOLINT directives that
cannot take effect" to the prose-lint check of this workflow. That is
safe here and was checked rather than assumed: `gh api
repos/.../branches/master/protection` returns 404 "Branch not protected"
and `gh api repos/.../rulesets` returns `[]`, so nothing lists a required
check by name. prose-lint's own job name is left alone -- it was already
a partial list of what the job runs, and renaming it would rename a check
for no gain.

Proof the gate still runs and still fails after the move: re-wrapping a
real directive in offline/replay_ledger.hpp onto two lines makes the
folded step exit 1 and name the file and line; reverting it returns
"166 NOLINTNEXTLINE directive(s), all annotating code", exit 0.

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

* ci: repoint two backend.hpp citations this rebase moved

Rebasing this branch onto a8511aa shifted the two `side_channel_metrics`
representative sites in `include/morph/core/backend.hpp`. Both entries
anticipated exactly this and recorded that the gate would report them as
ambiguous rather than print a corrected line, because each cites a statement
that appears character-for-character twice:

  include/morph/core/backend.hpp:1121 is allowlisted by a source line that
  appears 2 times (lines [1215, 1234]), and none of them is 1121, so which
  one is meant is not decidable. Make the entry unambiguous.

  include/morph/core/backend.hpp:1285 is allowlisted by a source line that
  appears 2 times (lines [1379, 1424]), and none of them is 1285, so which
  one is meant is not decidable. Make the entry unambiguous.

Resolved by reading the code at each candidate, which is what those notes ask
for. 1215 is the emission inside `registerModel`; 1234 is the
`registerModelShared` arm, and the entry names `registerModel`. 1379 is the
increment side -- `fetch_add`, `inFlightAfterInc`, outside the posted task;
1424 is the decrement twin inside it, and the entry names the increment side.

Neither `source` text nor `reason` is touched, so no triage is re-stated that
nobody performed. After: `python3 scripts/check_mutation_survivors.py` exits 0
with "15 structured citation(s) ... resolve to the line they name".

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>
Yaraslaut added a commit that referenced this pull request Sep 21, 2026
…uding TUs, and drop a deleted workflow from a docstring (fixes #646, fixes #643) (#653)

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

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

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

77 of the 84 are fixed rather than suppressed:

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

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

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

Verified, not asserted:

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Yaraslaut added a commit that referenced this pull request Sep 21, 2026
…ixes #656)

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

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

    97 findings inside the eleven sources themselves

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

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

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

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

The residual 30 needed judgement, one at a time:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    97 findings inside the eleven sources themselves

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

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

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

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

The residual 30 needed judgement, one at a time:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    97 findings inside the eleven sources themselves

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

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

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

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

The residual 30 needed judgement, one at a time:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Both vacuity traps, closed

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

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

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

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

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

## Verification status

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

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

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

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

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

---------

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

Labels

area: ci Subsystem: ci area: forms Subsystem: forms area: journal Subsystem: journal no docs update Skip the header<->spec sync gate for this PR

Projects

None yet

1 participant