lint: make wrapped NOLINT directives apply, gate the ones that cannot, and clear clang-tidy batch A (fixes #627, fixes #600) - #631
Merged
Conversation
…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
Member
Author
|
Follow-up issues filed from this work, per AGENTS.md:
Also recorded on #580: 🤖 Generated with Claude Code |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two tickets, one commit each, in this order deliberately: batch A's remedy is
partly "write an individually reasoned
NOLINT", and that cannot proceedhonestly 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
NOLINTNEXTLINEannotates the next physical line. Four directives had theirreason 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
include/morph/forms/forms.hpp:487cppcoreguidelines-missing-std-forward×2include/morph/forms/forms.hpp:494cppcoreguidelines-pro-bounds-avoid-unchecked-container-access×2tests/oom_injector.cpp:107cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memorytests/test_bridge_lifetime.cpp:519Each moves its reason above the directive rather than adding a
clang-format offguard, so the fix survives reformatting.fixed_string.hpp:48is untouched: it is the prose documenting this hazard, and its
clang-formatguard is the in-tree worked example of the other remedy.
Two judgement calls, stated rather than buried:
forms.hpp:487dropped a check it never needed. Onlymissing-std-forwardfires 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.
actionis boundby
glz::to_tieinto a tuple of references read member-by-member afterwards, somoving from it would leave the tie pointing at a moved-from object;
visitorisinvoked 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 reasoningis now the comment.
test_bridge_lifetime.cpp:519was both broken and unnecessary, as Four NOLINTNEXTLINE directives are wrapped onto two lines and suppress nothing; six findings leak past them #627suspected but could not confirm. Measured:
cppcoreguidelines-owning-memorydoesnot fire on placement new. Kept, now effective, so a later edit to that line
cannot reintroduce the finding silently. (The
owning-memoryfinding that doesexist in that file is at
:448, an unrelateddelete.)Before / after, measured
0e3b8823, clang-tidy 22.1.8,.clang-tidyunmodified, CI clang-tidy job's ownconfigure. Before — master's files, the leaked findings present:
After — all six gone;
grep -E "missing-std-forward|no-malloc"over the samelogs returns nothing, and
forms.hpp:49xis clean.The gate, and its non-vacuity
scripts/check_nolint_directives.shfails when aNOLINTNEXTLINEis followed bya 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, plusfixed_string.hpp: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 prosewarning about this hazard, not a directive.
And the mutation test asked for: re-wrap one of the four on the fixed tree.
Reverted:
(166 after batch A adds one.)
scripts/test_check_nolint_directives.shmakes that permanent, in the shapeevery 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.
Where the step lives, and why not where it belongs
It belongs in
.github/workflows/drift-guard.yml. Both candidate homes areheld right now: #614 holds
drift-guard.yml, #623 holdsci.yml, and both arestill open as of this push. So it is a new workflow,
.github/workflows/suppression-guard.yml, whose header says exactly that andsays folding it into
drift-guard.ymlchanges nothing about its behaviour.Tracked separately rather than left as a comment.
Stated residual
The scan anchors on
NOLINTNEXTLINEas the first token after the commentmarker. 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-accessstays enabled andits findings get fixed —
.at()or an individually reasonedNOLINT. A bareNOLINTappears nowhere in this PR.Batch A is 21 findings across 8 files;
include/morph/render/locale_format.hppis 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-tidyunmodified, configured with theclang-tidy job's own option set (
ci.yml:2191). Nine TUs: theverify_interface_header_setsstub for each batch-A header, plusquantity.hpp's stub,examples/forms/gui_qml/FormsController.cppandtests/test_quantity.cppfor the findings a standalone header stub cannot reachbecause it instantiates nothing. Deduplicated by path + line + column, as the
census did.
Before — the census row for row, with no adjustment:
After:
That zero is not a compile failure wearing a disguise. The same nine logs
still report 261 distinct findings elsewhere under
include/morph/(
forms.hpp104,quantity.hpp46,views.hpp40, …), no TU emitted aclang-diagnostic-error, and every target below built and ran.check → remedy → why
readability-use-concise-preprocessor-directivesattributes.hpp's#if defined(__has_cpp_attribute)→#ifdef. Lexical; this is #600's finding.readability-redundant-member-initaction_log.hpp'sstd::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-lengthfile_action_log.hpp'sstd::ifstream in→input. Nothing aboutinwas the vocabulary of an API, which is the only case where these renames lose.performance-unnecessary-value-paramforms_controller_core.hpp'ssubmitIfValid/fetchOptionstookstd::stringby value and passed it toexecuteJson, whose parameters arestd::string_view. Never moved, so the copies bought nothing.const std::string&, notstring_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.at())file_action_log.hpp'slines[i]. Not a hot path — one journal read. Hoisted out of the surroundingtry: the loop already boundsiso it cannot throw, but a.at()inside thattrywould have itsstd::out_of_rangecaught bycatch (const std::exception&)and mis-reported as a malformed journal line.cppcoreguidelines-macro-to-enum+modernize-macro-to-enum+cppcoreguidelines-macro-usageNOLINTBEGIN/END)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 aconstexprfunction 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-parametersrender/i18n.hpp'sresolveText. The check is right that swappingderivedKeyandschemaLiteralwould 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@briefstates it, as the body tries them, asdocs/spec/forms/forms.mdspecifies it, and asDynamicForm.qml'sresolveText(explicitKey, derivedKey, literal)mirrors it parameter for parameter. Reordering would desynchronise that pair. ATranslationKeystrong 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-cyclequantity_equation.hpp's include back intoquantity.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 iswrong 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'ssubmitIfValidtakes the same signature change. Its own doc comment says "Same body as
FormsControllerCore::submitIfValid", and that claim has to stay true. Not aseparate finding — the same declaration, duplicated.
What was built and run
Plus
clang-format --dry-run --Werrorclean on every touched file, andcheck_spec_citations.sh,check_catch_test_names.sh,check_rung_filters.sh,check_deprecated_markers.sh,check_ci_clang_pin.shall green.Review notes
Done inline rather than via
/code-review..at()hoist is the only behaviour-adjacent change in the PR, and it isa strict improvement: it moves a call that cannot throw out of a
catchthatwould have mishandled it if it could.
const std::string&is an API change on a public template. It issource-compatible in both directions (
submitIfValid(std::move(s), …)stillcompiles) and the spec cites these by parameter name, not type, so no spec
text moves.
{}from four members is the one change where "mechanical" couldhide a semantic difference. It does not: the members are
std::string, whosedefault constructor is non-trivial, so aggregate init, value init and default
init all produce an empty string with or without the initializer.
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
clang/clang-tidy 22.1.8, 12 cores. CI pins
CLANG_VERSION: "22"and adifferent libstdc++; the integers may differ, the shape should not.
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-lengthoninwas fixed by renaming withoutchecking whether
.clang-tidy'sIgnoredVariableNameswould have been thebetter answer for the batch-B
sha1.hppcases. That decision is deferred tobatch B, where 26 of them live, and is not prejudged here.
--fixwas run. Every edit is by hand.locale_format.hppis 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.morph_forms_controller_core_testsis the C++ half; the QML tests need a display.
ubuntu-24.04job running two bash scripts over ~700 files; measured locally atwell under a second, but that is a local number.
Filed separately
Per AGENTS.md, findings from this work that are not this change:
tests/.clang-tidy's check suppressions apply to headers reached from atests/TU, so a header gate driven by test TUs under-reports — measured, witha concrete case from this PR.
suppression-guard.ymlintodrift-guard.ymlonce ci: audit mutation_survivors.json's line citations instead of hoping (fixes #608) #614 lands.Both linked below once opened.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk