A command-line static analyzer for C++17/20, built on Clang LibTooling, AST
Matchers, and a hand-rolled lock-order graph analysis. Loads a
compile_commands.json, analyzes real translation units, and reports
defects with precise source locations.
This started as the MVP scoped in docs/spec.md -- three
rules, text/JSON/SARIF output, YAML config with suppression, and one
flagship cross-file rule with real graph-algorithm teeth -- and has since
grown through every item on that spec's Phase 2+ backlog: four more
rules (two sharing a generic worklist dataflow engine), parallel
translation-unit analysis, an incremental analysis cache, baseline
comparison, a differential/property/fuzzing/mutation-testing suite, a
synthetic benchmark corpus, and a sanitizer/multi-LLVM CI matrix. See
docs/architecture.md for the full internals
deep-dive.
.
├── include/sentinel/ Public headers: core/, rules/, graph/, config/,
│ output/, dataflow/ -- mirrors lib/ 1:1
├── lib/ Implementation
│ ├── core/ Diagnostic model, AnalysisDriver, IncrementalCache,
│ │ Baseline, SuppressionState, RuleFrontendAction, ...
│ ├── rules/ One .cpp per rule
│ ├── graph/ DirectedGraph + Tarjan's SCC
│ ├── config/ YAML config loading
│ └── output/ Text / JSON / SARIF formatters
├── tools/
│ ├── cpp-sentinel/ The CLI entry point (main.cpp)
│ └── fuzz/ Opt-in libFuzzer harness (SENTINEL_ENABLE_FUZZING)
├── tests/
│ ├── unit/ GoogleTest unit, fixture, and regression tests
│ ├── fixtures/ Per-rule positive/negative/boundary .cpp inputs
│ ├── projects/ Multi-file fixture projects (cross-file cycle, ...)
│ ├── golden/ Locked JSON output snapshot
│ └── support/ Shared test helpers (RuleTestSupport, ...)
├── benchmarks/
│ ├── synthetic-corpus/ Self-contained, template-generated benchmark corpus
│ └── generate_plots.py Regenerates docs/images/ from the numbers below
├── demo/ Small real project, one bug per rule
├── docs/
│ ├── architecture.md Deep-dive design doc
│ ├── spec.md Original MVP spec + Phase 2+ backlog
│ └── images/ README chart assets
├── mull.yml Mutation-testing config (opt-in)
└── .github/workflows/ CI: build/test/sanitizer/multi-LLVM matrix
flowchart TD
CDB[("compile_commands.json")] --> Driver["AnalysisDriver<br/>one task per TU, on a thread pool (--jobs)"]
Driver --> Cache{"Incremental cache hit?<br/>(--cache-dir)"}
Cache -- yes --> Restored["Restored diagnostics<br/>+ Rule::restoreState()"]
Cache -- no --> RFA["RuleFrontendAction<br/>(real ClangTool parse)"]
RFA --> AST["AST-matcher rules<br/>missing-override, redundant-move-return,<br/>unchecked-null-result, expensive-copy"]
RFA --> CFG["CFG + worklist dataflow<br/>use-after-move, unbalanced-lock"]
RFA --> LOI["lock-order-inversion<br/>AST walk, accumulates LockOrderFacts"]
AST --> Merge["Rule::mergeFrom()<br/>per-task instances merged into<br/>AnalysisDriver's template rules"]
CFG --> Merge
LOI --> Merge
Merge --> Finalize["Rule::finalize() -- once, after every TU<br/>lock-order-inversion: Tarjan's SCC<br/>over the merged cross-file lock graph"]
Restored --> Diagnostics["Diagnostic vector"]
Finalize --> Diagnostics
Diagnostics --> Filter["Suppression + baseline filtering"]
Filter --> Formatter["Text / JSON / SARIF formatter"]
compile_commands.json is loaded once, then AnalysisDriver hands each
translation unit to its own task on an llvm::DefaultThreadPool
(--jobs). Every task first checks the incremental cache
(--cache-dir); a hit restores that TU's diagnostics and any
serialized per-rule state without touching Clang at all. A miss runs a
real ClangTool parse through RuleFrontendAction, which dispatches
every rule one of two ways (see include/sentinel/core/Rule.h):
- AST-matcher rules (
missing-override,redundant-move-return,unchecked-null-result,expensive-copy) register aclang::ast_matchers::MatchFinderpattern and report directly from the callback. - CFG/dataflow rules (
use-after-move,unbalanced-lock) build aclang::CFGper function and run a generic forward "may" worklist dataflow fixed point over it (sentinel::dataflow::runForwardWorklist) -- the only rules that need a real fixed point, since a fact can flow into a loop body from the previous iteration via a back edge.lock-order-inversioninstead walks each function's AST directly, accumulating lock-acquisition facts into its ownLockFactStoreas it goes -- it doesn't need CFG-level precision, just "what locks could be held when this one is acquired."
Every rule gets a fresh instance per task (not a shared one guarded
by a lock), because an AST-matcher rule stashes its current TU's
TranslationUnitContext in a member between registerMatchers() and
run() -- two threads sharing an instance would race on that regardless
of per-field locking. Per-task instances are merged back into
AnalysisDriver's own "template" instances via Rule::mergeFrom() as
each task finishes.
Once every TU is processed, Rule::finalize() runs once per rule --
lock-order-inversion's real analysis lives here, since a cross-file
lock cycle only exists once every TU's facts are merged into one
project-wide graph. It builds a DirectedGraph from the accumulated
facts, runs Tarjan's SCC to find any cycle, and reconstructs the
shortest one for the diagnostic. The resulting diagnostics are filtered
through suppression comments and (if --baseline is given) marked known
vs. new, then handed to a text, JSON, or SARIF formatter.
See docs/architecture.md for the full pipeline
(including the incremental cache's two-tier key, the parallel merge
protocol in detail, and the testing infrastructure built on top of all
of this) with the original ASCII diagram this one is drawn from.
| Rule | Kind | What it catches |
|---|---|---|
missing-override |
AST | A virtual method that overrides a base method without the override keyword. |
redundant-move-return |
AST | return std::move(local);, which suppresses NRVO for no benefit. |
use-after-move |
CFG + worklist dataflow | A local variable or parameter read after std::move(...), with no reassignment in between. |
unbalanced-lock |
CFG + worklist dataflow | A path through a function that calls .lock() with no matching .unlock() before returning. |
unchecked-null-result |
AST | dynamic_cast<T*>(x) immediately dereferenced with no null check -- the pointer form returns nullptr on failure. |
expensive-copy |
AST | A by-value parameter of a non-trivially-copyable type that's only read, never mutated or moved-from. |
lock-order-inversion |
CFG + cross-file graph | A cycle in lock acquisition order across the whole project (a potential deadlock), found via Tarjan's SCC over a project-wide lock graph. |
lock-order-inversion is the flagship: it tracks which locks are held
through each function (explicit lock()/unlock() and
lock_guard/unique_lock RAII scopes), accumulates that as facts across
every translation unit in the run, and only after all of them are
processed builds the merged graph and looks for cycles. Two files can each
be individually lock-order-safe and still form a cycle once merged --
that's the case the flagship is built to catch (see
tests/projects/lock-cycle/).
use-after-move and unbalanced-lock are the two rules built on a
generic forward "may" dataflow engine (sentinel::dataflow:: runForwardWorklist), the only ones that need a real fixed point: a fact
(moved-from, still-locked) can flow into a loop body from the previous
iteration via a back edge -- unlike lock-order-inversion's single AST
walk, this genuinely needs the fixed point to catch that case (see
tests/fixtures/use-after-move/loop.cpp and
tests/fixtures/unbalanced-lock/conditional-leak-positive.cpp).
unbalanced-lock reuses lock-order-inversion's lock-identity resolution
(sentinel::resolveLockId) so both rules agree on what "the same lock"
means, but -- unlike lock-order-inversion -- only tracks explicit
lock()/unlock() calls, not RAII lock_guard/unique_lock (which are
balanced by construction; nothing to check).
--cache-dir <dir> enables an incremental analysis cache: a translation
unit whose main file, every header it transitively includes, its compile
command, the enabled rule set, --header-filter, and the cpp-sentinel
version all still match a prior run's cache entry is served straight from
that entry instead of being re-parsed and re-analyzed. On this project's
own ~50-TU self-scan, a fully warm cache brings a ~14s run down to
~0.5s -- with byte-for-byte identical findings, verified by diffing cold
vs. warm output. Unset (the default) disables caching entirely.
Requires CMake 3.20+, a C++20 compiler, and LLVM/Clang dev packages
(headers + the CMake config files, i.e.
LLVMConfig.cmake/ClangConfig.cmake). Developed against LLVM/Clang 22;
CI verifies both 21 and 22, each built two ways -- a normal Release
build, and a Debug build under ASan+UBSan -- so a version-specific or
sanitizer-catchable regression can't land silently on either axis. Other
reasonably recent versions likely work but aren't verified.
# macOS (Homebrew)
brew install llvm cmake
# Ubuntu/Debian
wget https://apt.llvm.org/llvm.sh && chmod +x llvm.sh && sudo ./llvm.sh 22 allcmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j"$(nproc)"
ctest --test-dir build --output-on-failureGoogleTest and yaml-cpp are pulled in automatically via FetchContent --
no need to install them separately. On macOS, CMake is pointed at
/opt/homebrew/opt/llvm by default; override with
-DCMAKE_PREFIX_PATH=<your LLVM prefix> if yours lives elsewhere.
ctest above covers unit/fixture tests, including two regression suites
worth calling out: Differential.* (byte-for-byte identical diagnostics
across --jobs counts and cache states) and PropertyBased.* (a rule's
findings never depend on which other rules are enabled alongside it).
Two more, both opt-in (not part of the default build or CI -- see
docs/architecture.md for the full story on
each):
- Fuzzing (
tools/fuzz/): a libFuzzer harness over the parse+rule pipeline. A 3-minute local session reached 29,060 executions and ~4,800 coverage edges with zero crashes. Build with-DSENTINEL_ENABLE_FUZZING=ONusing a Homebrew/apt Clang (needs a libFuzzer runtime Apple's system clang doesn't ship). - Mutation testing (mull, Linux only):
recompiles the rule/algorithm logic with small semantic mutations
(
</<=,==/!=, ...) and checks the test suite actually notices. A scoped run (fast fixture tests only, to fit a small VM's memory) found 69 mutants and killed 27 (39%) -- seedocs/architecture.mdfor why the score is scoped the way it is and which survivors are genuine gaps versus untested-in-this-run files. Build with-DSENTINEL_ENABLE_MUTATION_TESTING=ON -DMULL_IR_FRONTEND=/usr/lib/mull-ir-frontend-N.
cpp-sentinel analyze --compile-commands build/compile_commands.json
cpp-sentinel analyze --compile-commands build/compile_commands.json \
--format json --output report.json
cpp-sentinel list-rules
cpp-sentinel explain lock-order-inversionKey analyze options: --format text|json|sarif, --output <file>,
--enable/--disable <rule-id> (repeatable), --config <file.yaml>,
--include/--exclude <glob> (repeatable, controls which translation
units are analyzed), --header-filter <glob> (also report diagnostics
from headers matching this pattern; by default only each TU's main file
is reported, matching clang-tidy's convention), --fail-on warning|error,
-j/--jobs <N> (translation units analyzed in parallel; default 0 =
all available hardware threads), --cache-dir <dir> (enables the
incremental analysis cache; unset by default), --baseline <file> /
--update-baseline (see below).
Exit codes: 0 clean, 1 findings at/above --fail-on, 2 usage/config
error, 4 a translation unit failed to compile.
cpp-sentinel analyze --compile-commands build/compile_commands.json \
--format sarif --output results.sarifPaths are written repo-relative by default (stripping the current working
directory as a prefix -- override with --sarif-base-path), since GitHub's
Security tab expects repo-relative paths rather than the absolute ones a
real compile_commands.json carries. The output is ready to feed to
github/codeql-action/upload-sarif for GitHub code scanning, but CI
doesn't do that upload by default: it requires GitHub Advanced Security,
which isn't available on this (private) repo without a paid plan. Enable
it in repo Settings -> Code security and analysis (or make the repo
public, where code scanning is free) and add an upload step back to
.github/workflows/ci.yml if you want the
Security-tab integration.
Adopting a static analyzer on an existing codebase usually means it reports
a wall of pre-existing findings on day one. --baseline lets CI gate only
on new findings instead:
# Capture today's findings as the baseline (commit this file).
cpp-sentinel analyze --compile-commands build/compile_commands.json \
--baseline baseline.json --update-baseline
# Later runs: findings already in baseline.json are reported (marked
# `(baseline)` in text output, `"baselineKnown": true` in JSON,
# `"baselineState": "unchanged"` in SARIF) but excluded from --fail-on.
cpp-sentinel analyze --compile-commands build/compile_commands.json \
--baseline baseline.json --fail-on warningA baseline: <N> new, <M> known, <K> fixed summary is printed to stderr
each comparison run. Findings are matched by the same stable fingerprint
(rule id + file + line + column) the JSON/SARIF output already uses, so
the baseline file stays a plain, diffable JSON list that survives being
checked into version control -- re-run --update-baseline to intentionally
accept new findings or drop ones that no longer reproduce.
rules:
enable: [missing-override, redundant-move-return]
disable: [lock-order-inversion]int x = risky(); // cpp-sentinel-ignore some-rule
// cpp-sentinel-disable some-rule
... code some-rule won't be applied to ...
// cpp-sentinel-enable some-ruleEither form works with no rule id too (// cpp-sentinel-ignore suppresses
every rule on that line).
demo/ is a small, real two-file project with one bug per rule
(including a cross-file lock cycle spanning both files); see
demo/README.md for the exact runnable script.
One real run against spdlog at commit
989d28d
(v1.17.0) -- a real-world, mutex-heavy C++ logging library, ~27.5k LOC
across include/ + src/, 7 translation units in its compiled-library
configuration (-DSPDLOG_BUILD_EXAMPLE=OFF -DSPDLOG_BUILD_TESTS=OFF).
Measured on an 8-core Apple M3, Release build, average of 3 runs each,
--jobs 1 vs. the default (--jobs 0, all available hardware threads):
| Metric | --jobs 1 |
--jobs 0 (default) |
|---|---|---|
| Wall clock | 2.31 s | 0.68 s |
| Throughput | ~11,950 LOC/s | ~40,300 LOC/s |
| Peak RSS | ~199 MiB (209 MB) | ~680 MiB (712 MB) |
~3.4x wall-clock speedup on 7 translation units and 8 cores -- limited by
there being only 7 TUs to spread across the pool, not 8, plus thread-pool
startup overhead. Peak RSS scales up correspondingly, since parallel runs
keep multiple TUs' ASTs alive in memory at once instead of one at a time.
Both modes report byte-for-byte identical findings (verified via diff).
(The M4 benchmark's real redundant-move-return finding in spdlog's own
pattern_formatter-inl.h no longer surfaces under this toolchain: that
line sits behind #if defined(__GNUC__) && __GNUC__ < 5, which Clang
preprocesses out, so it was never actually visible to the analyzer in
this configuration -- not a regression, just a preprocessor conditional
worth being honest about rather than repeating a stale claim.)
To see parallel speedup past 8 cores, measured on a 32-vCPU GCP
c2d-standard-32 VM (Ubuntu 24.04, ephemeral -- provisioned, benchmarked,
and torn down for this one run), average of 3 runs per --jobs value.
Two corpora, since spdlog's 7 TUs cap meaningful parallelism regardless
of core count:
--jobs |
spdlog (7 TUs) | speedup | self-scan (53 TUs) | speedup |
|---|---|---|---|---|
| 1 | 6.64 s | 1.00x | 189.65 s | 1.00x |
| 2 | 3.77 s | 1.76x | 99.74 s | 1.90x |
| 4 | 3.01 s | 2.20x | 53.32 s | 3.56x |
| 8 | 2.35 s | 2.82x | 28.95 s | 6.55x |
| 16 | 2.35 s | 2.82x | 18.34 s | 10.34x |
| 32 | 2.35 s | 2.82x | 17.52 s | 10.82x |
spdlog plateaus immediately at 8 cores (exactly the 7-TU cap predicted
above -- more threads than TUs buys nothing). Self-scan, with more TUs
than cores at every step up to 32, keeps climbing, but with clearly
diminishing returns past 16 (16 -> 32 only gains ~5% more despite
doubling the core count) -- Amdahl's-law-style overhead (the final sort,
thread-pool teardown, and each task's own fixed per-TU setup cost) that
core count alone can't parallelize away. Peak RSS at --jobs 32 on the
self-scan was ~11.1 GiB (vs. ~0.98 GiB at --jobs 1) -- a real memory
cost worth knowing about before pointing high job counts at a large
project on a memory-constrained machine. All job counts produced
identical findings (verified via diff), including on the demo project
run directly on this VM.
The spdlog and scaling numbers above are real projects, but both are
external dependencies -- reproducing them means cloning something else
first. benchmarks/synthetic-corpus/ is
a self-contained alternative generated entirely from templates at CMake
configure time: 30 independent unit pairs (60 TUs), each reproducing the
same seven-rule bug pattern as tests/projects/multi-rule, for an exact,
predictable 210 total findings (30 per rule) -- a built-in correctness
check on the run, not just a timing number.
Measured on the same 8-core Apple M3, --jobs 1 (isolating the cache
effect from parallelism), average of 3 runs each, fresh --cache-dir per
cold run vs. one warm cache reused across all three warm runs:
| Wall clock | Peak RSS | |
|---|---|---|
| Cold (no cache) | 12.68 s | ~171 MiB |
| Warm (full cache hit, 60/60 TUs) | 0.87 s | ~34 MiB |
~14.6x speedup. All six runs (3 cold + 3 warm) produced byte-for-byte
identical findings (verified via diff), and every run reported exactly
210 findings, confirming the corpus's own self-check. Cold-run peak RSS
varied more than expected across repetitions (128-213 MiB) -- likely
ordinary background load on a shared dev laptop rather than anything
corpus-specific, included here rather than smoothed over.
This is now three real, reproducible data points (one external project,
one scaling study, one fully self-contained corpus) rather than "two
honest data points" -- what's still missing from a complete benchmark
suite is CI-enforced regression gating (fail a run if wall-clock or peak
RSS regresses past some threshold), which folds naturally into the CI
matrix work in docs/spec.md §14's next item rather than
being its own benchmark-only milestone.
- Clang LibTooling and AST Matchers -- the parsing/matching foundation every rule is built on.
clang::CFG-- the control-flow graphuse-after-moveandunbalanced-lockrun their worklist dataflow fixed point over.- Tarjan, R. E. (1972). Depth-first search and linear graph
algorithms. SIAM Journal on
Computing, 1(2), 146-160 -- the SCC algorithm behind
lock-order-inversion's cross-file cycle detection (lib/graph/SCC.cpp). - SARIF 2.1.0
-- the output format
--format sariftargets, including itsbaselineStatevocabulary, reused as-is by--baseline. - clang-tidy -- source of
the "only the TU's main file, unless
--header-filteropts a header in" conventionHeaderFilterfollows. - LLVM's libFuzzer -- the
coverage-guided fuzzing engine behind
tools/fuzz/. - Denisov, A., & Pankevich, S. (2018). Mull It Over: Mutation Testing
Based on LLVM. 2018 IEEE
ICSTW, 25-31 -- and mull itself, the
LLVM-pass-based mutation-testing tool
SENTINEL_ENABLE_MUTATION_TESTINGwires in. - spdlog -- the real-world project the Benchmark section's non-synthetic numbers are measured against.
- GoogleTest and
yaml-cpp -- the two external
dependencies, both pulled in via CMake
FetchContent. docs/spec.md-- the original MVP spec and Phase 2+ backlog this whole project was built against, milestone by milestone.
MIT -- see LICENSE.