ci: put every job banner over its job, and record what tests/.clang-tidy silences in headers (fixes #621, fixes #632) - #638
Conversation
…ixes #621) Seven of ci.yml's seventeen `# ── … ──` section banners sat above the *previous* job's trailing `sccache stats` / `Save sccache` steps, so each banner -- and for several jobs the multi-paragraph argument under it, which is the only written record of why that job exists -- described a job beginning 6-31 lines further down, behind another job's steps. `# ── Valgrind (memcheck) ──` was followed by three `linux-all-features` steps and only then by `valgrind:`. The move is comment-only. Verified against the base revision: banners old/new: 17 17 same multiset: True non-comment lines identical & in order: True 1133 1133 comment multiset identical: True 1173 1173 parsed YAML identical: True jobs: 18 so no job, step, key or expression changed -- only which lines the comments sit between. A displacement check on its own would have been vacuous: a file with every banner *deleted* scores a perfect zero displacements, which is this repository's named failure mode. scripts/check_workflow_job_banners.py therefore pairs both ways -- every banner must introduce a job, and every job must be introduced by a banner -- so deleting a banner fails rule B instead of satisfying rule A. Four jobs carry no banner today and are listed in UNBANNERED with a reason; the two hygiene rules from check_workflow_option_coverage.py's EXEMPT (an entry for a job that does not exist is an error, an entry for a job that now has a banner is an error) keep that list from rotting. Workflows with no banner at all are skipped, derived from the file rather than listed. Measured, not asserted: - Run against the pre-fix ci.yml the gate reports the seven displacements at 321, 711, 877, 967, 1363, 1641 and 1919 -- exactly the set #621 filed -- plus the seven now-bannerless jobs, and exits 1. - Run against this tree it exits 0 over all 24 banners in ci.yml and drift-guard.yml. - scripts/test_check_workflow_job_banners.sh drives twelve mutations: the founding displacement reproduced mechanically, a banner deleted rather than moved, a new job added without a banner, both UNBANNERED hygiene rules, the banner syntax reworded tree-wide (anti-vacuity), and four false-positive mirrors. All twelve pass. - python3 scripts/check_workflow_option_coverage.py . still exits 0 with the new job present. drift-guard.yml is the more natural home for the job and is where it belongs once PR #635 lands; it is held there now, so the job lives in ci.yml -- the same arrangement the option-coverage job is in. drift-guard.yml's own six banners were measured and are all correctly placed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…632) clang-tidy resolves its configuration from the path of the translation unit it analyses, not from the path of the file a diagnostic lands in. tests/.clang-tidy therefore scopes which TUs it governs, not which files their diagnostics may name: its thirteen suppressions -- each argued as Catch2 or raw-syscall idiom, correctly, about test code -- are also off for every include/morph/** header those TUs reach. A header gate driven by test TUs is green on findings it was built to catch. #632 confirmed the mechanism on one case and did not size it. Measured here, on a8511aa with clang-tidy 22.1.8, over the 134 tests/ TUs in the compile database the clang-tidy-diff job configures for itself (every optional feature ON; 703 entries, 134 distinct tests/ TUs, all 134 analysed, zero clang-diagnostic-error in either run): distinct findings inside include/morph/**, from those 134 TUs with tests/.clang-tidy in place .... 262 with it removed .................... 595 hidden by it ....................... 333, across 25 headers 247 cppcoreguidelines-pro-bounds-avoid-unchecked-container-access 57 readability-identifier-length 6 cppcoreguidelines-pro-bounds-array-to-pointer-decay 5 modernize-avoid-c-arrays 5 cppcoreguidelines-avoid-c-arrays 5 bugprone-unchecked-optional-access 3 cppcoreguidelines-pro-type-vararg 3 readability-convert-member-functions-to-static 2 readability-function-cognitive-complexity The set difference is one-directional: 0 findings appear with the file present that do not appear with it removed, which is what the mechanism predicts. The reach cannot be narrowed -- clang-tidy offers no way to scope a suppression to the file a diagnostic lands in -- so this takes #632's third close: write it down, and gate the record so it cannot go stale. - tests/.clang-tidy gains a section stating the reach, the measurement above, and a machine-readable `header-reach:` list naming all thirteen checks a test-TU-driven gate cannot report. - scripts/check_tidy_suppression_scope.sh checks both halves. Textually, that `header-reach:` names exactly what `Checks:` subtracts. Behaviourally, that the reach still exists: a probe header under include/morph/ is analysed from a TU under tests/ and from one that is not, using this repository's real .clang-tidy files, and the finding must be absent from the first and present from the second. The second probe is the anti-vacuity control -- the first assertion is a negative, which a probe that produces no findings at all satisfies by accident. Behavioural rather than a grep so that the note goes red rather than quietly stale if clang-tidy ever resolves configuration per diagnostic file. - Both run in the existing clang-tidy job, the only job that already has a pinned clang-tidy, before the Configure step: neither reads a compile database. Verified: - scripts/test_check_tidy_suppression_scope.sh drives nine cases -- a suppression added without a record entry, a record entry with no suppression, a suppression removed but recorded, the Checks: block reworded out from under the parser, the probed check ceasing to be suppressed, the probe rendered unable to fire from anywhere, and two false-positive mirrors. All nine pass. - The probe reproduces #632's mechanism independently of the sweep above: the same two checks are reported against include/morph/probe_scope.hpp from src/probe_scope_tu.cpp and not from tests/probe_scope_tu.cpp. On the nine examples/*/tests/ copies: #632 states they "carry the same entries". They do not -- each subtracts exactly one check, bugprone-chained-comparison. Their reach is the same but its effect is nil: that check fires only on Catch2's REQUIRE expansion and is attributed to the test source line. Measured over the 154 example test TUs in the same database, findings inside include/morph/** were identical with and without those nine files, 312 either way -- but that run cannot separate "no header reach" from "the check never fired", because Catch2 3.16.0 locally carries the NOLINT and bugprone-chained-comparison produced zero findings in either run. The structural argument carries the conclusion; the measurement only fails to contradict it. Recorded as such in tests/.clang-tidy, and noted on #632. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Runner verification (landing sweep, 2026-09-20)Checked against the repository rather than against the report. The safety claim holds; one figure in it does not describe what it says it describes. 1. No pre-existing executable line changes. Classifying every changed line in Zero deleted code lines is the property that matters, and it is exactly what a pure comment move must produce. Parsing base against head and diffing job by job agrees: 2. Correction to the report's figure. The hand-off states, for the #621 commit, "non-comment lines identical & in order: True 1133 1133 … parsed YAML identical: True jobs: 18". Measured at that commit, none of those three hold: because the commit also adds the 3. The gate is not vacuous. Ran Same seven, same line numbers as filed. The gate fails when the fix is absent, which is the check this repository's AGENTS.md asks for first. 4. The #635 coupling is real but benign, measured in both directions. The hand-off flags that three #635 adds steps to Not merged this sweep: CI is incomplete — Noted for the record: the lane volunteered a correction to its own ticket (#632's claim about the 🤖 Generated with Claude Code |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
… and name option-coverage by its job id (fixes #624, fixes #637) (#647) * ci: name the option-coverage job by its job id, not its script (fixes #637) linux-all-features' MORPH_BUILD_BANK_GUI rationale referred the reader to "the check-workflow-option-coverage job below". No job by that name exists in .github/workflows/: the job is `option-coverage`, and check_workflow_option_coverage.py is the script it runs. A reader who searches ci.yml for the name the comment gives finds nothing, and the sentence's whole purpose is to point at the standing guard. Measured on c55ea5b, before the change: $ grep -n 'check-workflow-option-coverage' .github/workflows/ci.yml 1838: # fetch requires. The check-workflow-option-coverage job below is $ grep -rn '^ check-workflow-option-coverage:' .github/workflows/ $ grep -n '^ option-coverage:' .github/workflows/ci.yml 2449: option-coverage: The replacement names both -- the job id a reader can jump to, and the script it runs -- so the next reader does not have to guess which of the two the sentence meant. Swept the rest of the tree for the same defect: over every `# ...` comment line in .github/workflows/*.yml, the hyphenated lowercase tokens immediately preceding the word "job" are automoc-include-lint, scenario-coverage, ladder-tests, linux-all-features, linux-coverage, option-coverage and dependency-free. All but the last resolve to a real job id in some workflow; "dependency-free job" is an adjective, not a reference. This was the only stale one. Nothing gates such a reference -- #638's check_workflow_job_banners.py pairs banners to jobs, and a job id inside a comment body is not a banner. That residual is accepted here and filed separately rather than folded in: closing it needs a new script plus the self-test this repository requires of a gate, which is its own change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW * ci: generate the two self-included .moc headers before clang-tidy-diff (fixes #624) examples/common/testkit/test_qml_surface.cpp and src/qt/forms/tests/ tst_main.cpp both end with `#include "<own-basename>.moc"` -- the AUTOMOC idiom for a Q_OBJECT declared in a .cpp. AUTOMOC writes that header at build time, the clang-tidy job configures and never builds, so every PR that touches either file failed the job on a parse error before a single changed line was analysed. Reproduced locally on c55ea5b, this job's configure flags, clang-tidy 22.1.8, one line added to each of the two files: /.../examples/common/testkit/test_qml_surface.cpp:757:10: error: 'test_qml_surface.moc' file not found [clang-diagnostic-error] /.../src/qt/forms/tests/tst_main.cpp:70:10: error: 'tst_main.moc' file not found [clang-diagnostic-error] exit=1 ## Why generating, and not suppressing The issue offered two fixes. Suppressing the diagnostic turns out not to be one of them: clang-tidy does not let a compiler error be filtered. Measured on tst_main.cpp with the moc absent, all three still exit 1 on the same line -- --- baseline (no filter) : exit=1 'tst_main.moc' file not found --- -checks=-clang-diagnostic-error : exit=1 'tst_main.moc' file not found --- -checks=-clang-diagnostic-* : exit=1 'tst_main.moc' file not found --- -warnings-as-errors= empty : exit=1 'tst_main.moc' file not found so "filter it" would have meant grepping clang-tidy-diff.py's output and overriding its exit code -- morph#479's defect, a gate that cannot fail, rebuilt on purpose. That is what the rejected option would have cost. Generating them costs a partial build, measured locally with USE_COMPILER_CACHE=OFF on 12 cores: 100 ninja edges, 67 compilations, 7 links, 9 moc runs, 31s. 39 of the 67 are the vendored Lightweight ORM, pulled in because a <target>_autogen target depends on its target's link dependencies. Building *every* autogen target instead was measured at 264 objects and 179s, so the step names the two targets it needs. Compiler caching is still not wired into this job: 67 objects against the 703 a real build of this configure would compile. ## The comment the issue was really about ci.yml's "No Build step" paragraph already reasoned about which generated files exist without a build, answered it for configure_file() output, and read as though it had settled the question. It now says which kind it covers, and points at the AUTOMOC step for the kind it does not. The -Wno-missing-include-dirs comment made the matching over-broad claim -- that no source includes a generated header -- when what its guard covers is an ascending `moc_<name>.h`, not a same-name `.moc`; corrected too. ## Verification Measured locally on this commit's tree, from a clean configure-only build directory (`rm -rf build/clang-debug`, configure, 0 .moc files on disk), running the step body extracted from ci.yml rather than a retyped copy: === 2. NEGATIVE CONTROL: the step's own check, run before the build === ::error::examples/common/testkit/test_qml_surface.cpp: test_qml_surface.moc was not generated -- ... ::error::src/qt/forms/tests/tst_main.cpp: tst_main.moc was not generated -- ... ok: 2 self-included .moc header(s), 2 missing check-before-build exit=1 (must be 1) === 3. VACUITY CONTROL: same check with the scan finding nothing === ::error::no tracked source self-includes a .moc -- this step's scan has stopped detecting the idiom it exists for empty-scan exit=1 (must be 1) === 4. the step as written === ok: examples/common/testkit/test_qml_surface.cpp -> test_qml_surface.moc ok: src/qt/forms/tests/tst_main.cpp -> tst_main.moc ok: 2 self-included .moc header(s), 0 missing step exit=0 A green clang-tidy-diff would prove nothing on its own, so the job was made to report a finding on the changed line in each of the two files. A deliberate `int laneProbeReachMarker() { return 0; }` inserted above each `.moc` include, with the job's own clang-tidy-diff arguments: --- violation: clang-tidy-diff exit=1 /.../src/qt/forms/tests/tst_main.cpp:69:5: error: function 'laneProbeReachMarker' can be made static or moved into an anonymous namespace [misc-use-internal-linkage,...] /.../examples/common/testkit/test_qml_surface.cpp:756:5: error: function 'laneProbeReachMarker' can be made static or moved into an anonymous namespace [misc-use-internal-linkage,...] clang-diagnostic-error count: 0 --- harmless: clang-tidy-diff exit=0 clang-diagnostic-error count: 0 Both translation units are now analysed to completion and the changed line is reached; the same diff before this change produced two clang-diagnostic-errors and no findings at all. Not verified: any of this on a GitHub hosted runner, or against aqtinstall's Qt ${QT_VERSION} rather than the local distro Qt 6.11.2 and apt.llvm.org's clang-tidy-22 rather than local 22.1.8. The CI timings will be worse than 31s on four cores. 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>
Two tickets on the CI / clang-tidy configuration tree, one commit each.
ci.yml's job banner comments sat above the previousjob's trailing steps. Moved, and gated.
tests/.clang-tidy's suppressions also silenceinclude/morph/headers. Sized, recorded, and gated.
Both are about the same failure mode from AGENTS.md: a control that reports
success while measuring nothing. In each case the obvious check is the vacuous
one, and the commit is mostly about not shipping that.
#621 —
ci: move seven job banners onto the jobs they describeReading
ci.ymltop to bottom,# ── Valgrind (memcheck) ──was followed bythree steps belonging to
linux-all-features, and only then byvalgrind:.Six more banners had the same shape. For several jobs the paragraphs under the
banner are the only written record of why the job exists —
# ── Linux: every rung's tests under AddressSanitizer + UBSan ──carries ~30 lines on why ASanand UBSan but not TSan, citing morph#128 — and a reader who scrolls into that
argument attributes it to whichever job's steps surround it.
The move is comment-only, and that is checked rather than claimed
Diffing the base revision's
ci.ymlagainst the moved one:Every non-comment line is byte-identical and in the same order; the set of
comment lines is unchanged; the parsed YAML is equal object-for-object. No job,
step, key or expression changed — only which lines the comments sit between.
With the new
banner-lintjob added, re-checked against the base with the newjob removed from the parse:
The vacuity trap, and what was done about it
A check that reports
displaced=0would pass equally well on a file with everybanner deleted — deletion removes displacements. So
scripts/check_workflow_job_banners.pyis a two-way pairing:comments and blank lines, the next line must be a top-level
<job-id>:key.and B goes red.
Four jobs legitimately carry no banner (
ci.yml'sprobe-self-hosted, whichis runner selection rather than a section;
drift-guard.yml's three-jobun-sectioned preamble). They are listed in
UNBANNEREDwith a reason, underthe two hygiene rules copied from
check_workflow_option_coverage.py'sEXEMPT: an entry for a job that does not exist is an error, and an entry fora job that does have a banner is an error. Workflows with no banner at all
are skipped, and that is derived from the file rather than listed —
docs.yml,mutation.yml,spec-sync.yml,suppression-guard.ymland the two wasmworkflows have never used the style, and a gate that demanded they adopt it
would be inventing a convention.
Listing the exempt jobs positively, rather than deriving "everything before the
file's first banner is preamble", is deliberate: under that derivation,
deleting a file's first banner would silently reclassify its job as preamble
and the deletion would go unreported.
Measured
ci.ymlthe gate reports the seven displacements at321, 711, 877, 967, 1363, 1641 and 1919 — exactly the set ci.yml: seven job banner comments sit above the previous job's trailing steps #621 filed — plus
the seven now-bannerless jobs, and exits 1.
ci.ymlanddrift-guard.yml.scripts/test_check_workflow_job_banners.shdrives twelve mutations and alltwelve pass:
drift-guard.ymlis the more natural home for the job and is where it belongsonce PR #635 lands; it is held there now, so the job lives in
ci.yml—the same arrangement the
option-coveragejob is in, and how #627/#633 werehandled. Its six banners were measured and are all correctly placed, so #621's
"re-open if the same pattern is found in another workflow" does not fire.
#632 —
lint: record and gate the reach tests/.clang-tidy actually hasclang-tidy resolves its configuration from the path of the translation
unit, not from the path of the file a diagnostic lands in.
tests/.clang-tidytherefore scopes which TUs it governs, not which files their diagnostics may
name: its thirteen suppressions — each argued as Catch2 or raw-syscall idiom,
correctly, about test code — are also off for every
include/morph/**headerthose TUs reach.
#632 confirmed the mechanism on one case and explicitly did not size it.
Sized
a8511aa6, clang-tidy 22.1.8, Arch Linux, against the compile database theclang-tidyjob configures for itself (clang-debug, every optional featureON: 703 entries, 134 distinct TUs under
tests/). The job's own option set.Each TU run twice — once with
tests/.clang-tidypresent, once with it movedaside. All 134 offered, all 134 analysed, zero
clang-diagnostic-errorineither run. Findings deduplicated on (file, line, column, check).
The set difference is one-directional — 0 findings appear with the file
present that do not appear with it removed — which is what the mechanism
predicts and would not hold if the two runs differed for some other reason.
The fix, and why this one and not the others
#632 offers three closes. The reach cannot be narrowed — clang-tidy offers no
way to scope a suppression to the file a diagnostic lands in — and building
#580's header gate is explicitly not in this batch. So: write it down, and gate
the record.
tests/.clang-tidygains a section stating the reach, the measurement above,and a machine-readable
header-reach:list naming all thirteen checks atest-TU-driven gate cannot report in a header.
scripts/check_tidy_suppression_scope.shchecks both halves. Textually,that
header-reach:names exactly whatChecks:subtracts. Behaviourally,that the reach still exists: a probe header under
include/morph/isanalysed from a TU under
tests/and from one that is not, using thisrepository's real
.clang-tidyfiles, and the finding must be absent fromthe first and present from the second.
clang-tidyjob — the only job that already has apinned clang-tidy on it — before the Configure step, since neither reads a
compile database.
The second probe is the anti-vacuity control and is the whole reason the
self-test exists. Assertion A is a negative — that a check does not fire —
which a probe that fails to compile, a misspelt check name, or an invocation
that analysed nothing all satisfy by accident. The behavioural form also means
the note goes red rather than quietly stale if clang-tidy ever starts resolving
configuration per diagnostic file.
Measured
The probe reproduces #632's mechanism independently of the 134-TU sweep:
scripts/test_check_tidy_suppression_scope.sh, nine cases, all passing:A correction to the ticket
#632 says "The same applies to the nine
examples/*/tests/.clang-tidycopies,which carry the same entries." They do not. All nine (plus
examples/common/testkit/.clang-tidy) are byte-identical to each other andeach subtracts exactly one check:
Their reach is the same; its effect is nil.
bugprone-chained-comparisonfires only on Catch2'sREQUIREexpansion, whosediagnostic is attributed to the test source line rather than to a morph header.
Measured over the 154 example test TUs in the same database, with and without
those nine files: 312 findings inside
include/morph/**either way, zerodifference.
That measurement proves less than it looks, and is written down as such:
bugprone-chained-comparisonproduced zero findings in either run,because Catch2 3.16.0 locally carries the
/* NOLINT(...) */the config file'sown comment describes. The run cannot separate "no header reach" from "the
check never fired". The structural argument carries the conclusion; the
measurement only fails to contradict it.
scripts/check_rung_filters.shstillpasses, and none of the nine files is touched.
Not folded in
ci.yml:1838points the reader at a job calledcheck-workflow-option-coverage, which does not exist — the job ci: build every declared MORPH_BUILD_* option, and let a red leg report every surface it measured (fixes #605, fixes #618) #623 addedis
option-coverage. Same class as ci.yml: seven job banner comments sit above the previous job's trailing steps #621 one field over (a comment naming thewrong job rather than sitting in the wrong place), found by grepping for the
job name while working ci.yml: seven job banner comments sit above the previous job's trailing steps #621. Not touched here.
clang-tidy-diff's changed-lines scope hides 101 findings in include/morph/, each a trap for the next edit to that line #580 rather than acted on. It sits within one of that census's 596 over a
full-tree sweep of 695 TUs, and within one and three of its two largest
per-check counts — which would mean the
tests/half of its proposed gaterecipe is close to redundant with the stub half while being far worse at
reporting. Not verified item-by-item, and clang-tidy-diff's changed-lines scope hides 101 findings in include/morph/, each a trap for the next edit to that line #580's own figure has not been
re-measured this sweep, so nothing here is built on it.
What is not verified
Linux, clang-tidy 22.1.8.
established behaviourally in both directions, not from the implementation.
mechanism and the record. Re-measuring is a 20-minute two-pass sweep and does
not belong in CI.
UNBANNEREDentries include three jobs indrift-guard.yml,which PR ci: split mutation_survivors.json's citations by what they are, and fold the NOLINT gate into drift-guard (fixes #613, fixes #633) #635 holds. If ci: split mutation_survivors.json's citations by what they are, and fold the NOLINT gate into drift-guard (fixes #613, fixes #633) #635 renames or removes one, the hygiene rule
fires and one dict line needs editing — loud and one-line, rather than a
silent hole, but the runner should know it is coupled.
check_workflow_option_coverage.pyexits 0 with bothci.ymledits inplace; CI itself has not been observed green at hand-off.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW