A local clang-tidy-diff run over the same diff, with the same clang-tidy
version and the same job flags, can exit 0 on a diff the CI job fails — and it
does so silently, reporting nothing at all rather than reporting less. The
divergence is readability-function-cognitive-complexity inside Catch2
TEST_CASE bodies, and the variable is the Catch2 the workstation has
installed, which nothing in the repository pins or checks.
This is how morph#656's branch shipped a NOLINT reason asserting, in the
tree, that a neighbouring TEST_CASE "scores under the threshold and stays
covered" while it scored 87 against a threshold of 25. The claim was not
careless: the local gate agreed with it.
Verification status
Reproduced, on aa84b126 (PR #665's head, base 7ab4c7a9), Arch Linux,
clang-tidy 22.1.8 — the same major version ci.yml pins
(CLANG_VERSION: "22"), so this is not version skew in clang-tidy. The
configure is the clang-tidy-diff job's own, flag for flag, and the
clang-tidy-diff.py invocation is the job's own (-path build/clang-debug -p1 -extra-arg=-std=c++23 -extra-arg=-Wno-missing-include-dirs -quiet),
over git diff -U0 origin/master.
What differs between the two runs below is only which Catch2 headers are on
the include path.
Workstation Catch2 (3.16.0, /usr/include/catch2)
$ python3 /usr/share/clang/clang-tidy-diff.py -path build/clang-debug \
-clang-tidy-binary clang-tidy -p1 -j 4 \
-extra-arg=-std=c++23 -extra-arg=-Wno-missing-include-dirs -quiet \
< one.diff
Running clang-tidy in 1 threads...
EXIT=0
The same run with Catch2 3.5.3 headers ahead of it (-isystem)
$ python3 /usr/share/clang/clang-tidy-diff.py -path build/clang-debug \
-clang-tidy-binary clang-tidy -p1 -j 4 \
-extra-arg=-std=c++23 -extra-arg=-Wno-missing-include-dirs \
-extra-arg=-isystem.../Catch2-3.5.3/src \
-extra-arg=-isystem.../b353/generated-includes -quiet \
< one.diff
.../examples/bank/tests/gui/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]
EXIT=1
That second line is character-for-character the error in the
clang-tidy-report artifact of CI run 35581623269, modulo the checkout path.
It is not that the score differs
clang-tidy computes the same score under both Catch2 versions. With the
workstation's 3.16.0 the finding exists and is then dropped:
$ clang-tidy -p build/clang-debug --checks='-*,readability-function-cognitive-complexity' \
--config='{InheritParentConfig: true, CheckOptions: {readability-function-cognitive-complexity.Threshold: "1"}}' \
--extra-arg=-std=c++23 --extra-arg=-Wno-missing-include-dirs --quiet \
examples/bank/tests/gui/test_bank_gui_qml_behaviour.cpp
(nothing for either TEST_CASE)
$ ... the same command with --system-headers added
.../test_bank_gui_qml_behaviour.cpp:115:1: error: function 'dummyFunction72' has cognitive complexity of 87 (threshold 1) ...
.../test_bank_gui_qml_behaviour.cpp:219:1: error: function 'dummyFunction76' has cognitive complexity of 45 (threshold 1) ...
So the check runs, the numbers are identical, and the whole difference is
whether ClangTidyDiagnosticConsumer classifies the finding as user code.
Mechanism, as far as it was established
checkFilters() is called for the error and for each of its notes, and both
LastErrorRelatesToUserCode and LastErrorPassesLineFilter are accumulated
across them — but a location inside a system macro returns early and
contributes nothing. A cognitive-complexity finding on a TEST_CASE is
therefore visible only through the notes that are not system macros: the
lambdas the test writes inside REQUIRE(pumpUntil([&]{ ... })), and its real
control flow. Which of those a given Catch2 release produces, and where it
puts them, is what changed between 3.5.x and 3.16.0.
This part is inferred from the behaviour plus a reading of
ClangTidyDiagnosticConsumer, not instrumented. The two exit codes above
are measured; the explanation for them is not.
A second, independent consequence of the same accumulation is measured, and
is worth writing down because it is counter-intuitive: the gate reports a
finding only when a changed line carries one of its user-code notes. A
one-line diff on REQUIRE(toastText != nullptr); — an assertion line inside a
case scoring 45 — reports nothing, because that line's only notes are system
macros. A one-line diff on REQUIRE(pumpUntil([&app] { ... })); in the same
case reports the 45 and exits 1. "I edited a line in an over-threshold
TEST_CASE" is therefore not the same condition as "the gate will fail".
Which Catch2 CI has
The artifact's macro-expansion notes place the runner's headers in the 3.5.x
series: catch_test_macros.hpp:142 is #define TEST_CASE( ... ) INTERNAL_CATCH_TESTCASE( __VA_ARGS__ ) and catch_unique_name.hpp:12/13/15
are ..._LINE2/..._LINE/INTERNAL_CATCH_UNIQUE_NAME, all of which match
Catch2 3.5.3 exactly. One line does not: the artifact renders
catch_test_registry.hpp:121 without the , __VA_ARGS__ that 3.5.3 has
there. Not verified: the exact package version of ubuntu-24.04's catch2.
Note that tests/.clang-tidy's header states it is 3.4.0, which cannot be
right — 3.4.0 has no CATCH_CONFIG_EXPERIMENTAL_STATIC_ANALYSIS_SUPPORT and
so cannot produce the dummyFunctionNN names the artifact is full of.
Why this matters beyond one comment
docs/ and several in-tree comments treat "run the clang-tidy job's configure
and clang-tidy-diff.py locally" as the way to check a branch before pushing.
For this check, on this file class, that procedure returns green on a red
diff. The failure is silent in both directions a reviewer might look: no
finding, no warning, no note that a check could not be evaluated.
What would change the verdict
- Close it if the job stops depending on the runner's Catch2 — e.g. the
clang-tidy job builds Catch2 from the version CMakeLists.txt already
pins for FetchContent (v3.8.1) rather than apt-get install catch2, so
local and CI analyse the same expansion. A pin plus a documented "install
this exact Catch2 to reproduce" would also close it.
- Close it if a gate makes the divergence loud instead of silent — for
instance, the job asserting the Catch2 version it actually compiled against,
so a local run against a different one is visibly not the same measurement.
- Re-open it if a second check turns out to diverge the same way; the
mechanism above is not specific to cognitive complexity, only to findings
whose evidence lives entirely in macro expansions.
A local
clang-tidy-diffrun over the same diff, with the same clang-tidyversion and the same job flags, can exit 0 on a diff the CI job fails — and it
does so silently, reporting nothing at all rather than reporting less. The
divergence is
readability-function-cognitive-complexityinside Catch2TEST_CASEbodies, and the variable is the Catch2 the workstation hasinstalled, which nothing in the repository pins or checks.
This is how morph#656's branch shipped a
NOLINTreason asserting, in thetree, that a neighbouring
TEST_CASE"scores under the threshold and stayscovered" while it scored 87 against a threshold of 25. The claim was not
careless: the local gate agreed with it.
Verification status
Reproduced, on
aa84b126(PR #665's head, base7ab4c7a9), Arch Linux,clang-tidy 22.1.8 — the same major version
ci.ymlpins(
CLANG_VERSION: "22"), so this is not version skew in clang-tidy. Theconfigure is the
clang-tidy-diffjob's own, flag for flag, and theclang-tidy-diff.pyinvocation is the job's own (-path build/clang-debug -p1 -extra-arg=-std=c++23 -extra-arg=-Wno-missing-include-dirs -quiet),over
git diff -U0 origin/master.What differs between the two runs below is only which Catch2 headers are on
the include path.
Workstation Catch2 (3.16.0,
/usr/include/catch2)The same run with Catch2 3.5.3 headers ahead of it (
-isystem)That second line is character-for-character the error in the
clang-tidy-reportartifact of CI run 35581623269, modulo the checkout path.It is not that the score differs
clang-tidy computes the same score under both Catch2 versions. With the
workstation's 3.16.0 the finding exists and is then dropped:
So the check runs, the numbers are identical, and the whole difference is
whether
ClangTidyDiagnosticConsumerclassifies the finding as user code.Mechanism, as far as it was established
checkFilters()is called for the error and for each of its notes, and bothLastErrorRelatesToUserCodeandLastErrorPassesLineFilterare accumulatedacross them — but a location inside a system macro returns early and
contributes nothing. A cognitive-complexity finding on a
TEST_CASEistherefore visible only through the notes that are not system macros: the
lambdas the test writes inside
REQUIRE(pumpUntil([&]{ ... })), and its realcontrol flow. Which of those a given Catch2 release produces, and where it
puts them, is what changed between 3.5.x and 3.16.0.
This part is inferred from the behaviour plus a reading of
ClangTidyDiagnosticConsumer, not instrumented. The two exit codes aboveare measured; the explanation for them is not.
A second, independent consequence of the same accumulation is measured, and
is worth writing down because it is counter-intuitive: the gate reports a
finding only when a changed line carries one of its user-code notes. A
one-line diff on
REQUIRE(toastText != nullptr);— an assertion line inside acase scoring 45 — reports nothing, because that line's only notes are system
macros. A one-line diff on
REQUIRE(pumpUntil([&app] { ... }));in the samecase reports the 45 and exits 1. "I edited a line in an over-threshold
TEST_CASE" is therefore not the same condition as "the gate will fail".
Which Catch2 CI has
The artifact's macro-expansion notes place the runner's headers in the 3.5.x
series:
catch_test_macros.hpp:142is#define TEST_CASE( ... ) INTERNAL_CATCH_TESTCASE( __VA_ARGS__ )andcatch_unique_name.hpp:12/13/15are
..._LINE2/..._LINE/INTERNAL_CATCH_UNIQUE_NAME, all of which matchCatch2 3.5.3 exactly. One line does not: the artifact renders
catch_test_registry.hpp:121without the, __VA_ARGS__that 3.5.3 hasthere. Not verified: the exact package version of ubuntu-24.04's
catch2.Note that
tests/.clang-tidy's header states it is 3.4.0, which cannot beright — 3.4.0 has no
CATCH_CONFIG_EXPERIMENTAL_STATIC_ANALYSIS_SUPPORTandso cannot produce the
dummyFunctionNNnames the artifact is full of.Why this matters beyond one comment
docs/and several in-tree comments treat "run the clang-tidy job's configureand
clang-tidy-diff.pylocally" as the way to check a branch before pushing.For this check, on this file class, that procedure returns green on a red
diff. The failure is silent in both directions a reviewer might look: no
finding, no warning, no note that a check could not be evaluated.
What would change the verdict
clang-tidyjob builds Catch2 from the versionCMakeLists.txtalreadypins for
FetchContent(v3.8.1) rather thanapt-get install catch2, solocal and CI analyse the same expansion. A pin plus a documented "install
this exact Catch2 to reproduce" would also close it.
instance, the job asserting the Catch2 version it actually compiled against,
so a local run against a different one is visibly not the same measurement.
mechanism above is not specific to cognitive complexity, only to findings
whose evidence lives entirely in macro expansions.