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

@ -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(