Skip to content

clang-tidy-diff run locally is silently blind to Catch2 TEST_CASE cognitive-complexity findings the CI job reports #666

Description

@Yaraslaut

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: ciSubsystem: cibugSomething isn't workingtriage: validWell-framed; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions