clang-tidy infrastructure

Table of Contents

1. Summary

clang-tidy is the static analyser of the LLVM toolchain. clang-format sets the layout of the code; clang-tidy reads its meaning. The one check the project runs today is misc-include-cleaner: every file includes the header for each symbol it uses, and no header it does not use. A run is on demand, outside the regular build, through build/scripts/check_includes.sh, so a normal build never pays for it. A finding in a generated file is fixed in its codegen template, never in the file. Return to Knowledge.

2. Detail

2.1. What it is for

A file that uses a symbol without its header compiles only while some other header brings that symbol in. When that other header changes, the file stops compiling. The compilers do not agree on which headers they pull in, so the fault shows on one platform only: std::to_string without <string> builds with libstdc++ and fails with MSVC. An unused include costs build time and hides the real dependencies of a file.

misc-include-cleaner finds both faults. It reports a symbol with no direct include ("no header providing X is directly included") and an include that provides no symbol ("included header X is not used directly").

The broader check set in .clang-tidy at the repository root (bugprone-*, modernize-* and others) is not run yet. A run with that set produces thousands of findings, so it needs a curated introduction of its own.

2.2. How to run it

The script reads a preset's compile database and runs clang-tidy over every translation unit in it:

./build/scripts/check_includes.sh linux-clang-debug-ninja

The preset defaults to linux-clang-debug-ninja, the one the nightly workflow builds. The configure step must have run first, because the script reads build/output/<preset>/compile_commands.json and it compiles nothing. The file list comes from the compile database and not from find, so a source the preset does not build is skipped instead of failing the run.

To fix the findings rather than report them, pass --fix. The nightly include workflow uses that mode and opens a pull request with the result. Parallelism is capped at four processes, because clang-tidy serialises fix application on shared files.

Generated files are skipped and named. Their includes come from the archetype templates under projects/ores.codegen/library/templates/, so a fix applied to the checked-in output is overwritten by the next regeneration and rejected by the codegen drift gate.

2.3. Generated code

Most C++ in the tree is generated, and the drift gate fails on any edit to a generated file. A finding in a file with the AUTO-GENERATED marker is therefore a defect in its template:

  • An include that every file of a template needs goes into the template unconditionally.
  • An include that some files need goes behind a context flag that core.py computes. The flag mirrors the template branches that spell the symbol, so the include appears exactly when one of them is rendered.
  • A header the check cannot see as used, such as one that only specialises a trait the file instantiates, keeps its include with // IWYU pragma: keep. and a comment that says why.

After a template change, regenerate the components and run the check again; it is the test of the change.

2.4. Known noise

A translation unit built with a precompiled header can confuse the check: it suggests cmake_pch.hxx as an include and reports used headers as unused. Where that happens, check the affected component with the precompiled header turned off, and treat the finding as noise until then.

2.5. Internal headers

The check suggests the header that defines a symbol, which is not always the header to include. A template whose definitions live in an _impl.hpp beside its public header is the common case: the check named domain_service_runner_impl.hpp instead of domain_service_runner.hpp, and the call that relied on a default argument stated only in the public header no longer compiled. Two guards stop it:

  • An internal header of ours names its public header with // IWYU pragma: private, include "<public header>", which the check honours.
  • A library header that cannot be marked keeps its include with // IWYU pragma: keep. and a comment that says why.

A fix the tool proposes is a proposal: build it before it merges.

2.6. Why not a CMake target

Three ways to run clang-tidy from CMake were weighed, and the project uses none of them. One script that reads the compile database runs the check on request, leaves a normal build unchanged, and gives the check one home.

  1. CMAKE_CXX_CLANG_TIDY. CMake runs clang-tidy beside the compile of each translation unit. The build graph schedules the work, so the -j of the build applies and only changed files are checked again. The cost decided against it: clang-tidy becomes part of each compile rule, so turning it on, or changing the checks, recompiles the whole tree, and every following compile is slower. It is the tool for a build that always lints, such as a dedicated CI preset, not for a run on request.
  2. A tidy target per CMake target. Each library gets a <target>_clang-tidy target with one command per source file, and an aggregate target depends on all of them. Qt's <target>_qmllint targets take this shape. The build graph schedules the targets, so the -j of the build applies across them, but the files inside one target run one after another, and every run checks every file again. ores.refdata.core has about a thousand files, so its target would hold up the end of each run. The CMake maintainers state that CMake has no built-in separate analysis target (CMake Discourse).
  3. One command that reads the compile database. A script or a driver such as LLVM's run-clang-tidy runs clang-tidy over the files in the database in parallel, filters them with --header-filter, and applies fixes safely. Nothing compiles, and a normal build is unchanged. This is the shape the project uses, through build/scripts/check_includes.sh.

3. See also

Emacs 29.3 (Org mode 9.6.15)