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

@ -204,7 +204,7 @@ void Configuration::GetInterfacesAndImplementations(std::span<fs::path> interfac
fileCopy.replace_extension("");
Implementation& implementation = this->implementations.emplace_back(std::move(fileCopy));
if (std::regex_search(fileContent, match, std::regex(R"(module ([a-zA-Z0-9_\.\-]+)(:[a-zA-Z0-9_\.\-]+)?\s*;)"))) {
bool isPartitionImpl = match[2].length() > 0;
const bool isPartitionImpl = match[2].length() > 0;
for(const std::unique_ptr<Module>& interface : this->interfaces) {
if(interface->name == match[1]) {
if (!isPartitionImpl) {
@ -275,7 +275,7 @@ CompileCommand Crafter::GetCompileCommand(const Configuration& config) {
// wasm32 targets reject -march and silently ignore -mtune (clang errors on
// the former). Skip both for any wasm32-* triple.
bool isWasm = config.target.starts_with("wasm32");
const bool isWasm = config.target.starts_with("wasm32");
std::string archFlags = isWasm
? std::string()
: std::format(" -march={} -mtune={}", config.march, config.mtune);
@ -1777,7 +1777,7 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) {
// server (browser build with index.html). std::system on the
// .wasm path goes nowhere useful — replace with detection.
if (config.target.starts_with("wasm32")) {
bool browserBuild = fs::exists(absDir / "index.html");
const bool browserBuild = fs::exists(absDir / "index.html");
auto have = [](std::string_view exe) {
#ifdef _WIN32
std::string probe = std::format("where {} > NUL 2>&1", exe);

View file

@ -279,7 +279,7 @@ ExternalBuildResult Crafter::BuildExternal(const ExternalDependency& dep, std::s
// LD_LIBRARY_PATH gymnastics. Static deps (.a) ignore the rpath
// harmlessly. PE/COFF targets (mingw, msvc) resolve DLLs via PATH
// or adjacency rather than rpath, so lld warns if we pass it.
bool isPe = target == "x86_64-w64-mingw32" || target == "x86_64-pc-windows-msvc";
const bool isPe = target == "x86_64-w64-mingw32" || target == "x86_64-pc-windows-msvc";
std::string buildDirAbs = fs::absolute(cmakeBuildDir).string();
result.linkFlags.push_back(std::format("-L{}", buildDirAbs));
if (!isPe) result.linkFlags.push_back(std::format("-Wl,-rpath,{}", buildDirAbs));
@ -375,7 +375,7 @@ Configuration* Crafter::GitProject(const GitProjectSpec& spec) {
fs::path cloneDir = externalRoot / std::format("{}-{}", name, srcHash);
{
bool exists = fs::exists(cloneDir);
const bool exists = fs::exists(cloneDir);
Progress::Task task(std::format("{} {}", exists ? "Updating" : "Cloning", name));
if (std::string err = FetchGit(spec.source, cloneDir); !err.empty()) {
throw std::runtime_error(std::format("GitProject({}): {}", spec.source.url, err));
@ -416,7 +416,7 @@ fs::path Crafter::GitFetch(const GitSource& source) {
fs::path cloneDir = externalRoot / std::format("{}-{:016x}", name, key);
{
bool exists = fs::exists(cloneDir);
const bool exists = fs::exists(cloneDir);
Progress::Task task(std::format("{} {}", exists ? "Updating" : "Cloning", name));
if (std::string err = FetchGit(source, cloneDir); !err.empty()) {
throw std::runtime_error(std::format("GitFetch({}): {}", source.url, err));

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) {

View file

@ -814,7 +814,7 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
return "";
}
} else {
bool isWasm = config.target.starts_with("wasm32");
const bool isWasm = config.target.starts_with("wasm32");
// wasi-sdk drops std.cppm at <sysroot>/share/libc++/v1/, the rest of
// the libc++ ecosystem (e.g. /opt/aarch64-rootfs) follows FHS at
// <sysroot>/usr/share/libc++/v1/.
@ -906,7 +906,7 @@ namespace {
// Re-derived under the lock: another builder may have refreshed the
// cache from its own sources while we waited.
std::string stamp = CrafterBuildSourceStamp(sourceDir, CrafterBuildModules);
bool upToDate = ReadCacheStamp(cacheDir) == stamp;
const bool upToDate = ReadCacheStamp(cacheDir) == stamp;
for (std::string_view name : CrafterBuildModules) {
fs::path cppmPath = sourceDir / std::format("{}.cppm", name);
fs::path pcmPath = cacheDir / std::format("{}.pcm", name);
@ -961,7 +961,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
EnsureCrafterBuildPcms(sourceDir, cacheDir);
bool stale = !fs::exists(soPath) || fs::last_write_time(soPath) < fs::last_write_time(absProject) || fs::last_write_time(soPath) < fs::last_write_time(hostExe);
const bool stale = !fs::exists(soPath) || fs::last_write_time(soPath) < fs::last_write_time(absProject) || fs::last_write_time(soPath) < fs::last_write_time(hostExe);
if (stale) {
std::string compileCmd = std::format(

View file

@ -61,7 +61,7 @@ namespace Crafter {
return out;
};
EShMessages messages = static_cast<EShMessages>(EShMsgDefault | EShMsgVulkanRules | EShMsgSpvRules);
const EShMessages messages = static_cast<EShMessages>(EShMsgDefault | EShMsgVulkanRules | EShMsgSpvRules);
std::ifstream fileStream(path, std::ios::in | std::ios::binary);
if (!fileStream) {
return fail("failed to open shader source", {});