Skip to content

Commit 2e043ca

Browse files
committed
Add use-after-move rule on a generic worklist dataflow engine (M6)
Phase 2+ backlog item #2: a small, Clang-independent forward "may" dataflow fixed-point engine (sentinel::dataflow::runForwardWorklist), plus a fourth rule built on it that flags reading a local/parameter after std::move(...) with no reassignment in between. This is the first rule needing a real fixed point across CFG blocks -- a moved-from fact can flow into a loop body from the previous iteration via a back edge, which lock-order-inversion's single AST walk can't see. Extracted FunctionCollector out of LockOrderInversionRule so both CFG-based rules share it. Self-scanning the codebase (same dogfooding discipline as M4/M5) caught a real false-positive class before it shipped: a VarDecl's own declaration wasn't treated as killing a prior moved-from fact tied to it, so a variable declared fresh inside a loop body looked moved-from on the next iteration once the fixed point carried the fact around the back edge. Fixed, with a regression fixture.
1 parent 4a9c2c4 commit 2e043ca

19 files changed

Lines changed: 812 additions & 29 deletions

README.md

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,19 @@ defects with precise source locations.
77

88
This is the MVP scoped in [`docs/spec.md`](docs/spec.md) -- three rules,
99
text/JSON/SARIF output, YAML config with suppression, and one flagship
10-
cross-file rule with real graph-algorithm teeth -- plus SARIF output from
11-
the Phase 2+ backlog. See [`docs/architecture.md`](docs/architecture.md)
12-
for how it's built internally.
10+
cross-file rule with real graph-algorithm teeth -- plus two items from the
11+
Phase 2+ backlog: SARIF output and a fourth rule, `use-after-move`, built
12+
on a generic worklist dataflow engine. See
13+
[`docs/architecture.md`](docs/architecture.md) for how it's built
14+
internally.
1315

1416
## Rules
1517

1618
| Rule | Kind | What it catches |
1719
|---|---|---|
1820
| `missing-override` | AST | A virtual method that overrides a base method without the `override` keyword. |
1921
| `redundant-move-return` | AST | `return std::move(local);`, which suppresses NRVO for no benefit. |
22+
| `use-after-move` | CFG + worklist dataflow | A local variable or parameter read after `std::move(...)`, with no reassignment in between. |
2023
| `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. |
2124

2225
`lock-order-inversion` is the flagship: it tracks which locks are held
@@ -28,6 +31,16 @@ be individually lock-order-safe and still form a cycle once merged --
2831
that's the case the flagship is built to catch (see
2932
`tests/projects/lock-cycle/`).
3033

34+
`use-after-move` is the other CFG-based rule, and the only one that needs
35+
a real fixed point: it runs a forward "may" dataflow analysis
36+
(`sentinel::dataflow::runForwardWorklist`, a small generic worklist engine
37+
reused by any future rule that needs one) over each function's
38+
`clang::CFG`, tracking which variables are moved-from and not yet
39+
reassigned. A moved-from fact can flow into a loop body from the
40+
*previous* iteration via a back edge -- unlike `lock-order-inversion`'s
41+
single AST walk, this genuinely needs the fixed point to catch that case
42+
(see `tests/fixtures/use-after-move/loop.cpp`).
43+
3144
## Building
3245

3346
Requires CMake 3.20+, a C++20 compiler, and LLVM/Clang dev packages
@@ -159,8 +172,18 @@ scaling studies).
159172
treated as an immediate acquisition.
160173
- `redundant-move-return` doesn't verify all of a function's return paths
161174
agree on the same NRVO candidate.
162-
- `--header-filter` and the main-file-only default only apply to the two
163-
per-TU AST rules (enforced in `TranslationUnitContext::report()`);
175+
- `use-after-move` only recognizes a direct `x = ...` assignment as
176+
reinitializing `x`; a method call that logically resets it
177+
(`x.clear()`, `x.reset()`) or passing `&x` to an out-parameter is not,
178+
and can produce a false positive on a use right after. It also doesn't
179+
specially model short-circuit (`&&`/`||`) or `?:` operators, which
180+
`clang::CFG` splits across blocks while the underlying AST subtree is
181+
still shared -- plain statements (the common shape of this bug) are
182+
unaffected. Diagnostics don't point back to the specific move site,
183+
since a "may" analysis with multiple incoming paths doesn't always have
184+
one unambiguous site to blame.
185+
- `--header-filter` and the main-file-only default only apply to the
186+
per-TU AST/CFG rules (enforced in `TranslationUnitContext::report()`);
164187
`lock-order-inversion`'s `finalize()`-time diagnostics don't currently
165188
go through that check.
166189

demo/README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
# cpp-sentinel demo script
22

3-
A ~5-minute walkthrough hitting all three rules, one of them cross-file.
4-
This directory is a tiny, real (buildable) two-file project with three
3+
A ~5-minute walkthrough hitting all four rules, one of them cross-file.
4+
This directory is a tiny, real (buildable) two-file project with four
55
intentional bugs:
66

77
- `shape.cpp`: `Circle::describe()` overrides `Shape::describe()` without
88
`override` (**missing-override**).
99
- `logger.cpp`: `buildGreeting()` does `return std::move(name);` on a
1010
local `std::string` (**redundant-move-return**).
11+
- `logger.cpp`: `logTwice()` moves `message` into `archived`, then passes
12+
`message` to `logEvent()` anyway (**use-after-move**).
1113
- `shape.cpp`'s `Circle::describe()` acquires `shapeMutex` then
1214
`logMutex`; `logger.cpp`'s `logEvent()` acquires them in the opposite
1315
order. Neither file is unsafe on its own -- merged, they form a lock
@@ -33,9 +35,9 @@ cmake -S demo -B demo/build
3335
```
3436

3537
Expected text output: a `missing-override` warning in `shape.cpp`, a
36-
`redundant-move-return` warning in `logger.cpp`, and a `lock-order-inversion`
37-
error naming both files' acquisition sites -- the cross-file cycle, the
38-
flagship result.
38+
`redundant-move-return` warning and a `use-after-move` warning in
39+
`logger.cpp`, and a `lock-order-inversion` error naming both files'
40+
acquisition sites -- the cross-file cycle, the flagship result.
3941

4042
```bash
4143
# 3. Same run, structured output.
@@ -48,6 +50,6 @@ flagship result.
4850
./build/tools/cpp-sentinel/cpp-sentinel explain lock-order-inversion
4951
```
5052

51-
That's the full demo: one command surfacing three different classes of
53+
That's the full demo: one command surfacing four different classes of
5254
defect, including one that only exists once two separate translation
5355
units are considered together.

demo/logger.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,9 @@ std::string buildGreeting() {
1010
std::string name = "world";
1111
return std::move(name);
1212
}
13+
14+
void logTwice(std::string message) {
15+
std::string archived = std::move(message);
16+
// Bug: message was just moved-from above -- triggers use-after-move.
17+
logEvent(message);
18+
}

docs/architecture.md

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,17 @@ RuleFrontendAction -> ASTConsumer |
1919
| (missing-override, |
2020
| redundant-move-return) |
2121
| - runOnTranslationUnit() |
22-
| walks the AST directly for |
23-
| CFG/graph-based rules |
24-
| (lock-order-inversion), |
25-
| accumulating LockOrderFacts |
26-
| into that rule's own |
27-
| LockFactStore |
22+
| for CFG-based rules: |
23+
| - lock-order-inversion |
24+
| walks the AST directly, |
25+
| accumulating |
26+
| LockOrderFacts into its |
27+
| own LockFactStore |
28+
| - use-after-move builds a |
29+
| clang::CFG per function |
30+
| and runs a forward |
31+
| worklist dataflow fixed |
32+
| point over it |
2833
+----+-----------------------------+
2934
|
3035
every TU processed
@@ -117,6 +122,54 @@ point, `std::unique_lock`'s `defer_lock` constructor form is treated as an
117122
immediate acquisition, and only `lock_guard`/`unique_lock` RAII wrappers
118123
are recognized (not `scoped_lock`).
119124

125+
## The generic worklist dataflow engine and `use-after-move`
126+
127+
`include/sentinel/dataflow/WorklistDataflowEngine.h` is a small, generic
128+
forward "may" dataflow fixed-point solver: `runForwardWorklist(nodes,
129+
entryIndex, analysis)` takes a plain index/adjacency graph (`Node` --
130+
deliberately not `clang::CFGBlock` itself, so the algorithm is
131+
unit-testable with a hand-built graph and no C++ parse, the same reasoning
132+
that keeps `graph/SCC.h` decoupled from any AST type -- see
133+
`WorklistDataflowEngineTest.cpp`) and an `Analysis` type supplying a
134+
`Domain`, `join`/`bottom`/`entry`, and a pure per-node `transfer`. It's
135+
the first rule in this codebase that needs a real fixed point rather than
136+
a single walk: `lock-order-inversion` visits each loop body once (a
137+
documented trim above), but a moved-from fact can flow into a loop body
138+
from the *previous* iteration via a back edge, so `use-after-move` needs
139+
the fixed point to catch that case correctly (see
140+
`tests/fixtures/use-after-move/loop.cpp`, and
141+
`WorklistDataflowEngineTest.LoopReachesFixedPointThroughBackEdge` for the
142+
engine-level version of the same scenario).
143+
144+
`UseAfterMoveRule` (`lib/rules/UseAfterMoveRule.cpp`) adapts a
145+
`clang::CFG` (one built per function via `CFG::buildCFG`) into that engine
146+
in two passes:
147+
148+
1. **Fixed-point pass**: `UseAfterMoveAnalysis::transfer()` walks each
149+
block's statements with `Domain = std::set<const VarDecl*>` (the
150+
currently moved-from-and-not-reassigned variables), joined by set union
151+
at merge points. This must be *pure* -- called repeatedly during
152+
iteration, so it never reports diagnostics itself.
153+
2. **Reporting pass**: once `IN[]` is known correct for every block, the
154+
rule re-walks each block's statements exactly once more from its final
155+
`IN[]`, this time actually reporting a diagnostic for every read of an
156+
already-moved-from variable.
157+
158+
Both passes share one `transferStatement()` helper (classify a
159+
statement's `DeclRefExpr`s into kills/gens/uses via a small
160+
`RecursiveASTVisitor`) so the fixed-point and reporting logic can't drift
161+
apart. Known limitations are documented in
162+
`include/sentinel/rules/UseAfterMoveRule.h` and the README; the one worth
163+
calling out here since it was caught by dogfooding rather than reasoned
164+
out ahead of time: a `VarDecl`'s own declaration has to be treated as a
165+
kill of any prior fact tied to that same declaration, or a variable
166+
declared fresh inside a loop body (e.g. `for (...) { Diagnostic diag; ...;
167+
report(std::move(diag)); }`) looks moved-from on the *next* iteration's
168+
brand-new instance, once the fixed point carries that fact around the back
169+
edge -- a real false positive this rule produced against its own
170+
`LockOrderInversionRule.cpp` and `SCC.cpp` before the fix (see
171+
`tests/fixtures/use-after-move/redeclared-each-iteration.cpp`).
172+
120173
## Header filtering
121174

122175
`TranslationUnitContext::report()` also checks `HeaderFilter`
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#pragma once
2+
3+
#include <clang/AST/Decl.h>
4+
#include <clang/AST/RecursiveASTVisitor.h>
5+
6+
#include <vector>
7+
8+
namespace sentinel {
9+
10+
// Collects every function definition (has a body, is the defining
11+
// declaration -- not a forward declaration) in a translation unit. Shared
12+
// by the per-TU rules that walk function bodies themselves rather than
13+
// through the MatchFinder (lock-order-inversion, use-after-move).
14+
class FunctionCollector : public clang::RecursiveASTVisitor<FunctionCollector> {
15+
public:
16+
std::vector<const clang::FunctionDecl *> functions;
17+
18+
bool VisitFunctionDecl(clang::FunctionDecl *decl) {
19+
if (decl->hasBody() && decl->isThisDeclarationADefinition())
20+
functions.push_back(decl);
21+
return true;
22+
}
23+
};
24+
25+
} // namespace sentinel
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#pragma once
2+
3+
#include <cstddef>
4+
#include <queue>
5+
#include <vector>
6+
7+
namespace sentinel::dataflow {
8+
9+
// One node in the graph the engine iterates over. Deliberately not
10+
// clang::CFGBlock itself: keeping the engine's graph representation a
11+
// plain index/adjacency structure means the fixed-point algorithm can be
12+
// unit-tested with a hand-built graph, no real C++ parse required (see
13+
// WorklistDataflowEngineTest.cpp) -- the same reasoning that keeps
14+
// graph/SCC.h decoupled from any AST type. A caller adapting a real
15+
// clang::CFG builds one Node per CFGBlock (indexed by getBlockID()) from
16+
// that block's preds()/succs().
17+
struct Node {
18+
std::vector<size_t> predecessors;
19+
std::vector<size_t> successors;
20+
};
21+
22+
// Forward, "may"-style iterative worklist dataflow fixed point. Direction
23+
// is always forward and the join is always caller-supplied (typically set
24+
// union for a "may" analysis) -- nothing in this codebase needs a backward
25+
// or "must" analysis yet, so those aren't generalized for here.
26+
//
27+
// `Analysis` must provide:
28+
// - using Domain = ...; (copyable, comparable via equal())
29+
// - Domain entry() const; IN[[entryIndex]]
30+
// - Domain bottom() const; identity element for join: join(bottom(), x) == x
31+
// - Domain join(const Domain &a, const Domain &b) const;
32+
// - bool equal(const Domain &a, const Domain &b) const;
33+
// - Domain transfer(size_t nodeIndex, Domain in) const;
34+
//
35+
// transfer() must be pure: called repeatedly during fixed-point iteration,
36+
// so it must not have observable side effects (e.g. must not report
37+
// diagnostics itself -- a caller that needs to act at the exact
38+
// statement/instruction where a fact becomes relevant should re-walk each
39+
// node's contents starting from the returned IN[node] once the fixed
40+
// point below has been reached, since this engine only computes
41+
// node-boundary state).
42+
//
43+
// Returns IN[node] for every node, indexed the same as `nodes`.
44+
template <typename Analysis>
45+
std::vector<typename Analysis::Domain> runForwardWorklist(const std::vector<Node> &nodes,
46+
size_t entryIndex,
47+
const Analysis &analysis) {
48+
using Domain = typename Analysis::Domain;
49+
50+
std::vector<Domain> in(nodes.size(), analysis.bottom());
51+
std::vector<Domain> out(nodes.size(), analysis.bottom());
52+
std::vector<bool> queued(nodes.size(), false);
53+
std::queue<size_t> worklist;
54+
55+
auto enqueue = [&](size_t index) {
56+
if (!queued[index]) {
57+
queued[index] = true;
58+
worklist.push(index);
59+
}
60+
};
61+
62+
for (size_t i = 0; i < nodes.size(); ++i)
63+
enqueue(i);
64+
65+
while (!worklist.empty()) {
66+
size_t index = worklist.front();
67+
worklist.pop();
68+
queued[index] = false;
69+
70+
Domain newIn = index == entryIndex ? analysis.entry() : analysis.bottom();
71+
for (size_t pred : nodes[index].predecessors)
72+
newIn = analysis.join(newIn, out[pred]);
73+
74+
in[index] = newIn;
75+
Domain newOut = analysis.transfer(index, newIn);
76+
if (!analysis.equal(newOut, out[index])) {
77+
out[index] = std::move(newOut);
78+
for (size_t succ : nodes[index].successors)
79+
enqueue(succ);
80+
}
81+
}
82+
83+
return in;
84+
}
85+
86+
} // namespace sentinel::dataflow
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#pragma once
2+
3+
#include "sentinel/core/Rule.h"
4+
5+
namespace sentinel::rules {
6+
7+
// Flags a read of a local variable or parameter after it was passed to
8+
// std::move(...), with no reassignment in between -- the object is left
9+
// in a valid-but-unspecified moved-from state, and reading it there is
10+
// almost always a bug rather than intentional.
11+
//
12+
// Built on the generic forward worklist dataflow engine
13+
// (sentinel::dataflow::runForwardWorklist), the first rule in this
14+
// codebase that needs a real fixed point across clang::CFG blocks: unlike
15+
// lock-order-inversion's single AST walk (loop bodies visited once),
16+
// a moved-from fact can flow into a loop body from the *previous*
17+
// iteration via a back edge, so getting this right needs the fixed point.
18+
//
19+
// Domain: the set of VarDecls currently moved-from and not yet
20+
// reassigned, joined by union at merge points ("may" analysis: flagged if
21+
// a use is reachable from the move along *any* path, even if other paths
22+
// reassign first -- consistent with warning-tool practice of erring
23+
// toward reporting a real, reachable bug rather than staying silent
24+
// because *some* path is safe).
25+
//
26+
// Known limitations (deliberate MVP trims, documented rather than
27+
// silently mishandled):
28+
// - Only a direct `x = ...` assignment is recognized as reinitializing
29+
// x; a method call that logically resets it (`x.clear()`, `x.reset()`)
30+
// or passing `&x` to an out-parameter is not, and can produce a false
31+
// positive on a use immediately after.
32+
// - Doesn't specially model short-circuit (`&&`/`||`) or `?:` operators,
33+
// which clang::CFG splits across multiple blocks/elements while the
34+
// underlying AST subtree is still shared; a move or use inside such an
35+
// operator's untaken/split branch may be mis-ordered relative to real
36+
// control flow. Plain statements (declarations, assignments, calls,
37+
// returns) -- by far the common shape of this bug -- are unaffected.
38+
// - Only variables of class type are tracked (via getAsCXXRecordDecl());
39+
// moving a scalar is a no-op, so there's nothing meaningful to flag.
40+
// - The diagnostic doesn't point back to the specific move site: under a
41+
// "may" analysis with multiple incoming paths, there isn't always one
42+
// unambiguous site to point to.
43+
class UseAfterMoveRule : public Rule {
44+
public:
45+
std::string id() const override { return "use-after-move"; }
46+
std::string shortDescription() const override {
47+
return "Local variable or parameter used after being passed to "
48+
"std::move(...), with no reassignment in between";
49+
}
50+
51+
void runOnTranslationUnit(TranslationUnitContext &context) override;
52+
};
53+
54+
} // namespace sentinel::rules

lib/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ add_library(sentinel-lib OBJECT
3939
rules/MissingOverrideRule.cpp
4040
rules/RedundantMoveReturnRule.cpp
4141
rules/LockOrderInversionRule.cpp
42+
rules/UseAfterMoveRule.cpp
4243
)
4344

4445
target_include_directories(sentinel-lib PUBLIC

lib/rules/LockOrderInversionRule.cpp

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#include "sentinel/rules/LockOrderInversionRule.h"
2+
#include "sentinel/core/FunctionCollector.h"
23
#include "sentinel/core/RuleRegistry.h"
34
#include "sentinel/core/SuppressionState.h"
45
#include "sentinel/core/TranslationUnitContext.h"
@@ -7,7 +8,6 @@
78

89
#include <clang/AST/ASTContext.h>
910
#include <clang/AST/ExprCXX.h>
10-
#include <clang/AST/RecursiveASTVisitor.h>
1111
#include <clang/Basic/SourceManager.h>
1212

1313
#include <llvm/Support/MemoryBuffer.h>
@@ -219,17 +219,6 @@ class FunctionLockWalker {
219219
}
220220
};
221221

222-
class FunctionCollector : public RecursiveASTVisitor<FunctionCollector> {
223-
public:
224-
std::vector<const FunctionDecl *> functions;
225-
226-
bool VisitFunctionDecl(FunctionDecl *decl) {
227-
if (decl->hasBody() && decl->isThisDeclarationADefinition())
228-
functions.push_back(decl);
229-
return true;
230-
}
231-
};
232-
233222
std::string edgeKey(const std::string &from, const std::string &to) {
234223
return from + "\x1f" + to;
235224
}

0 commit comments

Comments
 (0)