Skip to content

Repository files navigation

C++ Static Analyzer

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.

Repo layout

.
├── 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

Architecture

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"]
Loading

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 a clang::ast_matchers::MatchFinder pattern and report directly from the callback.
  • CFG/dataflow rules (use-after-move, unbalanced-lock) build a clang::CFG per 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-inversion instead walks each function's AST directly, accumulating lock-acquisition facts into its own LockFactStore as 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.

Rules

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.

Building

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 all
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j"$(nproc)"
ctest --test-dir build --output-on-failure

GoogleTest 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.

Testing beyond unit tests

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=ON using 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%) -- see docs/architecture.md for 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.

Usage

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-inversion

Key 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.

SARIF / code scanning

cpp-sentinel analyze --compile-commands build/compile_commands.json \
    --format sarif --output results.sarif

Paths 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.

Baseline comparison

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 warning

A 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.

Config file

rules:
  enable: [missing-override, redundant-move-return]
  disable: [lock-order-inversion]

Suppression

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-rule

Either form works with no rule id too (// cpp-sentinel-ignore suppresses every rule on that line).

Demonstration

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.

Benchmark

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)
Bar charts: spdlog wall clock drops from 2.31s (jobs=1) to 0.68s (default); peak RSS rises from 199 MiB to 680 MiB.

~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.)

Scaling study

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
Line chart: spdlog's speedup climbs to 2.82x by --jobs 8 and plateaus (7 TUs cap parallelism); self-scan's speedup keeps climbing to 10.82x at --jobs 32.

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.

Synthetic corpus: cold vs. warm cache

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
Bar charts: synthetic-corpus wall clock drops from 12.68s (cold) to 0.87s (warm); peak RSS drops from 171 MiB to 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.

References

  • Clang LibTooling and AST Matchers -- the parsing/matching foundation every rule is built on.
  • clang::CFG -- the control-flow graph use-after-move and unbalanced-lock run 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 sarif targets, including its baselineState vocabulary, reused as-is by --baseline.
  • clang-tidy -- source of the "only the TU's main file, unless --header-filter opts a header in" convention HeaderFilter follows.
  • 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_TESTING wires 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.

License

MIT -- see LICENSE.

About

Command-line C++17/20 static analyzer built on Clang LibTooling and AST Matchers, with a flagship cross-file lock-order-inversion rule found via Tarjan's SCC.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages