feat(lint): const-local and constexpr-constant rules

Two rules the AST makes possible, plus the mutation analysis behind them.

const-local reports a local that is never written. It is restricted to SCALARS
— integers, bools, enums, floating types — and that restriction is what makes
the answer exact rather than a guess: a scalar has no member functions, so the
only ways to write one are assignment, ++/--, having its address taken, or
binding to a non-const reference. All four are now tracked in the walk:

  - assignment and compound assignment visit their LEFT operand in a write
    context, the right one normally;
  - ++/-- and & write their operand;
  - a call argument is checked against the callee's parameter type, so passing
    to `const int&` or by value is a read while `int&` is a write;
  - initialising a non-const reference writes what it binds to.

For a class type a non-const method call could mutate it, and deciding that is
the whole-program analysis clang-tidy does, so those are simply out of scope
rather than guessed at.

constexpr-constant promotes a const constant whose initialiser is made only of
literals and operators, so `const int A = 1 << 4;` qualifies and
`const int B = Compute();` does not.

On this repository const-local found 103 candidates, which was too many to be
useful, and the reason was informative: most were range-for bindings and
pointer locals. `for (T* const x : …)` and `T* const p` are not spellings
anybody writes, and the useful constness for a pointer is on the pointee, which
this rule cannot advise on. Excluding both leaves 36, all plain bool or enum
locals worth fixing — isWasm, isPe, exists, writes, isC and so on. Those 36 are
fixed in this commit; the compiler verified every one.

Both rules are report-only. The analysis is exact, but adding const is a
judgement about intent as much as mechanics, and a wrong suggestion should cost
a glance rather than a build. const-local also deliberately does not become a
transform: inserting `const` before a shared type would apply it to every
declarator in a multi-declarator statement, including any that IS written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-07-31 00:50:48 +02:00
commit 651720e494
10 changed files with 370 additions and 39 deletions

View file

@ -88,6 +88,14 @@ namespace {
decltype(&clang_getFileName) getFileName = nullptr;
decltype(&clang_Cursor_isNull) cursorIsNull = nullptr;
decltype(&clang_getResultType) getResultType = nullptr;
decltype(&clang_Cursor_getBinaryOpcode) getBinaryOpcode = nullptr;
decltype(&clang_getCursorUnaryOperatorKind) getUnaryOperatorKind = nullptr;
decltype(&clang_isConstQualifiedType) isConstQualifiedType = nullptr;
decltype(&clang_CXXMethod_isConst) methodIsConst = nullptr;
decltype(&clang_CXXMethod_isStatic) methodIsStatic = nullptr;
decltype(&clang_getArgType) getArgType = nullptr;
decltype(&clang_getNumArgTypes) getNumArgTypes = nullptr;
decltype(&clang_getPointeeType) getPointeeType = nullptr;
};
LibClang LoadLibClang() {
@ -166,6 +174,22 @@ namespace {
bind(lib.getFileName, "clang_getFileName");
bind(lib.cursorIsNull, "clang_Cursor_isNull");
bind(lib.getResultType, "clang_getResultType");
bind(lib.getBinaryOpcode, "clang_Cursor_getBinaryOpcode");
bind(lib.getUnaryOperatorKind, "clang_getCursorUnaryOperatorKind");
bind(lib.isConstQualifiedType, "clang_isConstQualifiedType");
bind(lib.methodIsConst, "clang_CXXMethod_isConst");
bind(lib.methodIsStatic, "clang_CXXMethod_isStatic");
bind(lib.getArgType, "clang_getArgType");
bind(lib.getNumArgTypes, "clang_getNumArgTypes");
bind(lib.getPointeeType, "clang_getPointeeType");
bind(lib.getBinaryOpcode, "clang_Cursor_getBinaryOpcode");
bind(lib.getUnaryOperatorKind, "clang_getCursorUnaryOperatorKind");
bind(lib.isConstQualifiedType, "clang_isConstQualifiedType");
bind(lib.methodIsConst, "clang_CXXMethod_isConst");
bind(lib.methodIsStatic, "clang_CXXMethod_isStatic");
bind(lib.getArgType, "clang_getArgType");
bind(lib.getNumArgTypes, "clang_getNumArgTypes");
bind(lib.getPointeeType, "clang_getPointeeType");
if (!missing.empty()) {
lib.handle = nullptr;
lib.error = std::format("loaded {} but it is missing {}", candidates.front(), join(missing));
@ -364,8 +388,63 @@ namespace {
std::vector<LintDecl>* out = nullptr;
std::vector<std::size_t> stack; // indices of the enclosing declarations
std::int32_t externCDepth = 0; // inside how many extern "C" blocks
// >0 while visiting a subtree whose value is being WRITTEN: the left
// side of an assignment, the operand of ++/--, or anything whose
// address is taken or which binds to a non-const reference.
std::int32_t writeDepth = 0;
// Set while visiting the binding of a range-for.
bool inLoopBinding = false;
// Name offset -> index, so a DeclRefExpr can be resolved back to the
// declaration it names without comparing USR strings.
std::unordered_map<std::size_t, std::size_t> byNameOffset;
};
bool IsScalarTypeKind(CXTypeKind kind) {
switch (kind) {
case CXType_Bool:
case CXType_Char_U:
case CXType_UChar:
case CXType_UShort:
case CXType_UInt:
case CXType_ULong:
case CXType_ULongLong:
case CXType_Char_S:
case CXType_SChar:
case CXType_Short:
case CXType_Int:
case CXType_Long:
case CXType_LongLong:
case CXType_Float:
case CXType_Double:
case CXType_LongDouble:
case CXType_Enum:
case CXType_Pointer:
return true;
default:
return false;
}
}
bool IsAssignmentOpcode(CX_BinaryOperatorKind opcode) {
return opcode >= CX_BO_Assign && opcode <= CX_BO_OrAssign;
}
bool IsWritableReference(const LibClang& lc, CXType type) {
if (type.kind != CXType_LValueReference) return false;
return lc.isConstQualifiedType(lc.getPointeeType(type)) == 0;
}
CXChildVisitResult CollectChild(CXCursor cursor, CXCursor, CXClientData data) {
static_cast<std::vector<CXCursor>*>(data)->push_back(cursor);
return CXChildVisit_Continue;
}
std::vector<CXCursor> ChildrenOf(const LibClang& lc, CXCursor cursor) {
std::vector<CXCursor> children;
lc.visitChildren(cursor, &CollectChild, &children);
return children;
}
// Whether a CXCursor_LinkageSpec is `extern "C"` as opposed to
// `extern "C++"`. libclang exposes no query, and the spelling is empty, but
// the extent starts at the `extern` keyword so the source answers it.
@ -401,17 +480,31 @@ namespace {
return !PathInsideRoot(fs::path(path), projectRoot);
}
void ProcessCursor(CXCursor cursor, DeclWalk& walk);
CXChildVisitResult VisitDecl(CXCursor cursor, CXCursor, CXClientData data) {
DeclWalk& walk = *static_cast<DeclWalk*>(data);
ProcessCursor(cursor, *static_cast<DeclWalk*>(data));
return CXChildVisit_Continue;
}
// Visit a cursor's children with the write-context flag raised, so every
// DeclRefExpr inside counts as a write to what it names.
void ProcessAsWrite(CXCursor cursor, DeclWalk& walk) {
++walk.writeDepth;
ProcessCursor(cursor, walk);
--walk.writeDepth;
}
void ProcessCursor(CXCursor cursor, DeclWalk& walk) {
const LibClang& lc = *walk.lc;
CXSourceLocation location = lc.getCursorLocation(cursor);
// Every declaration the imported modules bring in arrives here too —
// an unfiltered visit of one interface unit walks ~495,000 cursors from
// std alone. Prune before doing any work.
if (!lc.locationIsFromMainFile(location)) return CXChildVisit_Continue;
if (!lc.locationIsFromMainFile(location)) return;
CXCursorKind kind = lc.getCursorKind(cursor);
LintDeclKind mapped = MapCursorKind(kind);
const CXCursorKind kind = lc.getCursorKind(cursor);
const LintDeclKind mapped = MapCursorKind(kind);
if (mapped == LintDeclKind::Other) {
// A foreign reference inside a VARIABLE, FIELD or PARAMETER is an
// initialiser binding that declaration to somebody else's API —
@ -421,11 +514,78 @@ namespace {
// own signature.
if (!walk.stack.empty()) {
LintDecl& enclosing = (*walk.out)[walk.stack.back()];
bool initialiserContext = enclosing.kind == LintDeclKind::Variable || enclosing.kind == LintDeclKind::Field || enclosing.kind == LintDeclKind::Parameter;
const bool initialiserContext = enclosing.kind == LintDeclKind::Variable || enclosing.kind == LintDeclKind::Field || enclosing.kind == LintDeclKind::Parameter;
if (initialiserContext && ResolvesOutsideProject(lc, cursor, *walk.projectRoot)) {
enclosing.isForeignApi = true;
}
}
// ---- mutation analysis ----
// A name used where a value is being written marks that
// declaration mutated. Resolving through getCursorReferenced means
// shadowing and qualified names come out right.
if (kind == CXCursor_DeclRefExpr && walk.writeDepth > 0) {
CXCursor target = lc.getCursorReferenced(cursor);
if (!lc.cursorIsNull(target)) {
std::uint32_t targetOffset = 0;
lc.getFileLocation(lc.getCursorLocation(target), nullptr, nullptr, nullptr, &targetOffset);
if (auto it = walk.byNameOffset.find(targetOffset); it != walk.byNameOffset.end()) {
(*walk.out)[it->second].isMutated = true;
}
}
}
// The left side of an assignment is written; the right side is read.
if (kind == CXCursor_BinaryOperator || kind == CXCursor_CompoundAssignOperator) {
if (IsAssignmentOpcode(lc.getBinaryOpcode(cursor))) {
std::vector<CXCursor> children = ChildrenOf(lc, cursor);
if (!children.empty()) {
ProcessAsWrite(children.front(), walk);
for (std::size_t c = 1; c < children.size(); ++c) ProcessCursor(children[c], walk);
return;
}
}
}
// ++/-- write their operand; & lets it be written elsewhere, which
// we cannot follow, so it counts as mutated.
if (kind == CXCursor_UnaryOperator) {
const CXUnaryOperatorKind unary = lc.getUnaryOperatorKind(cursor);
const bool writes = unary == CXUnaryOperator_PreInc || unary == CXUnaryOperator_PreDec || unary == CXUnaryOperator_PostInc || unary == CXUnaryOperator_PostDec || unary == CXUnaryOperator_AddrOf;
if (writes) {
for (CXCursor child : ChildrenOf(lc, cursor)) ProcessAsWrite(child, walk);
return;
}
}
// An argument bound to a non-const lvalue reference can be written
// by the callee.
if (kind == CXCursor_CallExpr) {
std::vector<CXCursor> children = ChildrenOf(lc, cursor);
CXType callee = lc.getCursorType(lc.getCursorReferenced(cursor));
std::int32_t params = lc.getNumArgTypes(callee);
if (params > 0) {
// Children are [callee?, args...]; line them up from the end
// so an implicit callee child does not shift the mapping.
std::size_t firstArg = children.size() > static_cast<std::size_t>(params)
? children.size() - static_cast<std::size_t>(params) : 0;
for (std::size_t c = 0; c < children.size(); ++c) {
const bool byWritableRef = c >= firstArg && IsWritableReference(lc, lc.getArgType(callee, static_cast<std::uint32_t>(c - firstArg)));
if (byWritableRef) ProcessAsWrite(children[c], walk);
else ProcessCursor(children[c], walk);
}
return;
}
}
// The first child of a range-for is its binding; the rest are the
// range expression and the body.
if (kind == CXCursor_CXXForRangeStmt) {
std::vector<CXCursor> children = ChildrenOf(lc, cursor);
for (std::size_t c = 0; c < children.size(); ++c) {
const bool binding = c == 0 && lc.getCursorKind(children[c]) == CXCursor_VarDecl;
walk.inLoopBinding = binding;
ProcessCursor(children[c], walk);
walk.inLoopBinding = false;
}
return;
}
// Recursed by hand rather than with CXChildVisit_Recurse so the
// enclosing-declaration stack stays accurate: the callback is never
// told when a subtree ends.
@ -439,14 +599,14 @@ namespace {
if (specBegin < walk.content->size()) {
text = std::string_view(walk.content->data() + specBegin, std::min<std::size_t>(specEnd - specBegin, walk.content->size() - specBegin));
}
bool isC = IsExternCLinkage(text);
const bool isC = IsExternCLinkage(text);
if (isC) ++walk.externCDepth;
lc.visitChildren(cursor, &VisitDecl, &walk);
if (isC) --walk.externCDepth;
return CXChildVisit_Continue;
return;
}
lc.visitChildren(cursor, &VisitDecl, &walk);
return CXChildVisit_Continue;
return;
}
LintDecl decl;
@ -456,7 +616,7 @@ namespace {
// whole function type: the parameters arrive as their own Parameter
// declarations, so spelling them here too would make every rule
// reading `type` report each one twice.
bool callable = mapped == LintDeclKind::Function || mapped == LintDeclKind::Method;
const bool callable = mapped == LintDeclKind::Function || mapped == LintDeclKind::Method;
decl.type = TakeString(lc, lc.getTypeSpelling(callable ? lc.getResultType(lc.getCursorType(cursor)) : lc.getCursorType(cursor)));
CXFile nameFile = nullptr;
std::uint32_t nameLine = 0;
@ -491,11 +651,27 @@ namespace {
}
decl.parent = walk.stack.empty() ? LintNoParent : walk.stack.back();
CXType declaredType = lc.getCursorType(cursor);
decl.isConst = lc.isConstQualifiedType(declaredType) != 0;
decl.isScalar = IsScalarTypeKind(declaredType.kind);
decl.isLoopVariable = walk.inLoopBinding;
if (mapped == LintDeclKind::Method) {
decl.isConstMethod = lc.methodIsConst(cursor) != 0;
decl.isStaticMethod = lc.methodIsStatic(cursor) != 0;
}
walk.out->push_back(std::move(decl));
walk.stack.push_back(walk.out->size() - 1);
lc.visitChildren(cursor, &VisitDecl, &walk);
std::size_t index = walk.out->size() - 1;
walk.byNameOffset.emplace(nameOffset, index);
walk.stack.push_back(index);
// Binding a name to a non-const reference — `auto& r = x;` — lets x be
// written through r, which we cannot follow, so x counts as mutated.
if (IsWritableReference(lc, declaredType)) {
for (CXCursor child : ChildrenOf(lc, cursor)) ProcessAsWrite(child, walk);
} else {
lc.visitChildren(cursor, &VisitDecl, &walk);
}
walk.stack.pop_back();
return CXChildVisit_Continue;
}
std::vector<LintDecl> WalkDecls(const LibClang& lc, CXTranslationUnit tu, const std::string& content, const fs::path& projectRoot) {