Skip to content

Static analysis

What clang-tidy checks, why those checks and not the others, and what the current findings are. Companion to the code quality policy, which covers compiler warnings and formatting.

Before this existed, the baseline recorded static analysis as not configured — no clang-tidy, no cppcheck, no findings, and therefore no claim about the codebase in either direction.

Field Value
Tool clang-tidy 22.1.8, pinned — see Pinning the tool
Configuration .clang-tidy at the repository root
Runner tools/quality/run_clang_tidy.py
Machine-readable clang-tidy-baseline.json
Scope 255 production translation units
Findings 69 (was 90; #306 closed the 21 correctness findings)
Runtime ~9 minutes at 14-way parallelism

Pinning the tool

The version is part of the baseline. clang-tidy-baseline.json records the one that generated it, CI installs exactly that, and --check refuses to compare across a mismatch rather than warning about it.

pip install clang-tidy==22.1.8     # the version the baseline records

PyPI publishes LLVM's own binaries as wheels, pinnable to the patch and the same on all three platforms. Chocolatey has no package at that granularity, which is what defeated the first attempt and left the job using whatever LLVM the runner image shipped.

To move to a new clang-tidy, regenerate the baseline with it. The workflow needs no edit — it reads the version out of the baseline and installs that. The version is still written out on this page and in code-quality-policy.md for readers, so a bump means the baseline plus those prose mentions; grep for the old number.

Why a mismatch is fatal rather than noted

It was a warning until #317, and both ways it can go wrong happened under that warning:

Direction What happened
CI's version reports what the baseline's does not A pull request failed on misc-redundant-expression at PIPE_TYPE_BYTE \| PIPE_READMODE_BYTE \| PIPE_WAIT — three Windows SDK macros that are all 0x00000000, so 20 reads the line as 0 \| 0 \| 0. main had it too. Loud.
The baseline's version reports what CI's does not misc-use-internal-linkage in src/ui/tree_view.cpp reached main unreported, and the next regeneration would have adopted it as pre-existing. Silent, and worse.

A phantom failure gets diagnosed, because somebody is blocked by it. A phantom pass is indistinguishable from a clean run. --ignore-tool-version exists for measuring what a version change would cost, and is never a basis for a merge.

Running it

python tools/quality/run_clang_tidy.py            # analyze and write the baseline
python tools/quality/run_clang_tidy.py --check    # fail if findings grew
python tools/quality/run_clang_tidy.py --report   # print every finding
python tools/quality/run_clang_tidy.py --all-checks   # measure what the exclusions cost

The project must have been configured at least once (cmake --preset default), because the analysis needs the FetchContent dependency headers under build/_deps. If they are absent the runner says so and exits rather than analyzing a subset and reporting it as a clean result.

While iterating on one file, --check --filter <substring> analyzes only matching paths. It narrows coverage, so it cannot write a baseline and does not gate a merge; the runner refuses the combination that would let it.

Choosing the checks

The selection principle: a check earns its place by finding defects in this codebase, not by being enabled in someone else's.

Every family was run over all 255 translation units before being kept or dropped. The four enabled families with no exclusions produce 4,784 findings; the enabled set produced 90 when it was first measured. The difference is not leniency — nine checks account for 4,694 of the raw count (98%), five of them for 4,586 between them, and each is wrong here for a reason:

Re-derive the counts rather than trusting this table:

python tools/quality/run_clang_tidy.py --all-checks
Excluded check Count Why
misc-include-cleaner 3,357 Demands include-what-you-use. CLAUDE.md states the opposite policy: SortIncludes is disabled and include order is preserved by hand, because Windows and some third-party headers have order dependencies. Enforcing it would break the build it protects.
misc-const-correctness 756 Real but purely stylistic. 756 edits across every subsystem is the "broad stylistic churn" #293 exists to avoid. A candidate for a later deliberate pass.
misc-non-private-member-variables-in-classes 241 ui::UiState, core::AppState and the SACM element structs are deliberately public data aggregates. Accessors carrying no invariant are not an improvement.
bugprone-easily-swappable-parameters 141 Its own documentation concedes a high false-positive rate. Here it fires on (id, name) string pairs whose names already distinguish them.
performance-enum-size 91 Micro-optimization of enum underlying types, with no measured motivation in a GUI application.
bugprone-exception-escape 50 Measured, not assumed. It fires on implicitly-generated move constructors of aggregate structs holding std::string/std::vector (SacmElement, MultiLangText, ReviewProposal) and on main(). It found no hand-written noexcept function that can throw, which is the defect the check exists for.
misc-no-recursion 28 This codebase walks trees — assurance tree, GSN layout, XMI nesting. Recursion is the design.
misc-use-anonymous-namespace 28 Flags static where the function already sits inside an anonymous namespace. Removing a redundant keyword at 28 sites is naming-only churn.
clang-analyzer-optin.performance.Padding 2 Reordering ui::UiState's 38 fields to save padding would scramble a struct people read, for memory nobody has measured a need for.

An unexplained exclusion is indistinguishable from a check nobody dared turn on, which is why each carries its count and its reason. Any of these can be reinstated; the argument against each is recorded so it can be contested.

Current findings

69 findings. All 21 that described a correctness problem were triaged and closed by #306; what remains is style and performance preferences, baselined so the count cannot grow.

By area

Area Findings
src/core 24
src/ui 22
src/app 14
libs/sacm 2
src/bridge 2
src/sacm_adapter 2
src/ai 1
src/export 1
src/mcp 1

libs/sacm contributing 2 of 69 is worth noting: the reusable library every SACM conformance claim rests on is the cleanest part of the tree by this measure. src/core, src/ui and src/app hold 60 of the 69 between them, which is roughly what their share of the code predicts — this ranks nothing on its own, and is not a defect density.

What the correctness findings turned out to be

The 21 findings whose checks describe a correctness problem — rather than a style or performance preference — were the subject of #306. The result is worth recording, because it is not the one the triage expected:

None of the 21 was reachable. Every one was a real observation about code being harder to read than it needed to be; none was a defect a user could hit. For the largest group, twelve unchecked std::optional accesses, the deciding fact was that core::AppState::current_project is assigned in two places and never reset(), so no path can empty it once a project is open.

Outcomes: 20 restructured so the invariant is visible to both a reader and the analyser, and 1 justified as a false positive with its reason recorded in the source. One NOLINT carries a rationale; a second, in bridge/transport.cpp, suppresses an unrelated Windows-SDK false positive and is tracked for removal under #317.

And the one genuine defect in that code was not among them. Reviewing the bugprone-narrowing-conversions fix in core/project_file_io.cpp surfaced that ReadFileBytes reported success on a short read, returning a zero-padded buffer — into Sha256File, which produces the hashes af.proj records for every tracked file. No enabled check reported it; a reviewer reading the surrounding function did.

That is not an argument against the tool. It is the argument for baselining and ratcheting one rather than treating it as the definition of correctness: "clang-tidy is clean" and "this code is right" are different claims.

The ratchet

--check compares against the committed baseline and fails when a count grows.

What runs when

A full sweep is far too slow to sit in front of every pull request. The first attempt at the CI job ran all 255 translation units on a 4-core runner and was still going after 30 minutes, against a repository whose median CI wall clock is 12.7 minutes. So scope depends on the trigger:

Trigger Scope
Pull request Only the .cpp files the branch touched (--paths)
Push to main The full sweep — the authoritative one
Local Whatever you ask for; --filter for one file

The gap that leaves is worth naming rather than discovering: a pull request that edits a header can add findings in .cpp files it did not touch, and those are not analyzed until the full sweep runs after the merge. Closing it before the merge would mean resolving the include graph to find every affected translation unit, which this does not do.

A branch that changes no .cpp file analyzes nothing and passes. That is the correct outcome, not a broken run — but it is why the sweep on main is the one to believe.

It keys on (file, check), deliberately not on line numbers. Line numbers move whenever anything above them is edited, so a line-keyed baseline would fail on unrelated changes and teach people to regenerate it without reading it — which is how a gate stops being a gate.

Consequences of that choice, stated rather than discovered later:

  • Adding a new instance of a check to a file that already has one is caught, because the count rises.
  • Moving an existing finding within a file is not caught, correctly.
  • Removing one finding and adding another of the same check in the same file nets to zero and is not caught. This is the known hole. It is the price of a baseline that survives ordinary editing.

Findings are never auto-fixed. FormatStyle: none, and no --fix anywhere: a tool rewriting safety-case handling code unattended is not a trade this project makes.

A translation unit that fails to analyze

clang-tidy reports a translation unit it could not compile on stderr and exits non-zero, while stdout stays empty. Reading stdout alone would take that as "no findings here" — so a missing include path or define could make the ratchet pass on code nothing actually looked at, which is the one way a green gate is worse than no gate.

The runner therefore checks the exit status of every translation unit and aborts the whole run if any failed, naming each one and the diagnostic that caused it. It does not write a baseline from a partly-failed run, because every later comparison would then be against a baseline that recorded unanalyzed code as clean.

All 255 currently analyze cleanly, so this guards a failure that has not happened rather than one that has.

Verifying the gate can fail

A gate that has never failed is a gate nobody has tested. This one was verified by breaking the code on purpose: an (int)(2.5 + 0.5) cast was added to assurance_tree.cpp, --check reported

src/core/assurance_tree.cpp: bugprone-incorrect-roundings went from 0 to 1

and exited 1. The cast was then removed and the check passed again. The same was verified for --paths, which is what the pull-request job actually runs, rather than assuming the two share a code path.

The incomplete-run guard was verified the same way: an #include of a header that does not exist produced

1 translation unit(s) could not be analyzed:

  src/bridge/transport.cpp
      exit 1: ...error: 'this_header_does_not_exist_af.h' file not found

and exited 2 — rather than reporting the file clean, which is what it did before the check existed.

Known gaps

Listed rather than omitted, because a gap nobody has written down reads as a gap nobody has.

Gap Detail
Windows only The baseline is generated with clang targeting the Windows build, so #ifndef _WIN32 branches are not analyzed. This is the mirror image of the compiler-sweep problem in the code quality policy: a platform that is not analyzed is not clean, it is unmeasured.
Production only tests/ and libs/sacm/tests/ are excluded. A different risk profile, and including them would roughly triple runtime for findings nobody ships. Revisit under #292.
Toolchain headers dropped Findings outside the repository are discarded. MSVC's own <filesystem> implementation contributed three; they describe someone else's code and would pin the baseline to whoever generated it.
~~CI's clang-tidy may differ from the baseline's~~ Closed by #317. CI installs the version the baseline records, and a mismatch is now fatal rather than warned about — see Pinning the tool. While it was open it cost one phantom pull-request failure and let one real finding reach main.
No cppcheck A second opinion would catch what clang-tidy misses. Not configured.
Not a CTest gate At ~9 minutes locally (longer on a 4-core CI runner) it does not belong in the suite developers run constantly. It runs as its own CI job.
Pull requests see only changed .cpp files See What runs when. A header edit can add findings elsewhere that only the full sweep on main catches.

Reproducing

Prerequisites: Python 3.10+, the pinned clang-tidy, and a configured build tree.

pip install clang-tidy==22.1.8   # the version the baseline records; --check refuses any other
cmake --preset default           # once, so build/_deps exists
python tools/quality/run_clang_tidy.py

The runner takes CLANG_TIDY if set, otherwise the first clang-tidy on PATH. If a system LLVM shadows the pinned one, point at it explicitly:

CLANG_TIDY=$(python -c "import clang_tidy;print(clang_tidy.get_executable('clang-tidy'))") \
  python tools/quality/run_clang_tidy.py --check

Unlike the repository baseline, which describes one commit and is expected to go stale, this baseline is meant to stay current. It is checked on every pull request, and it should be regenerated whenever findings are fixed so the improvement is locked in.