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

@ -430,12 +430,72 @@ int main() {
"enum struct AlsoFine { E };\n",
"enum-class", LintMode::Report);
Check(r.summary.findings.size() == 2, std::format("enum-class: exactly the two plain enums ({} found)", r.summary.findings.size()));
bool onFirst = std::any_of(r.summary.findings.begin(), r.summary.findings.end(), [](const LintFinding& f) { return f.line == 1; });
bool onSplit = std::any_of(r.summary.findings.begin(), r.summary.findings.end(), [](const LintFinding& f) { return f.line == 2; });
const bool onFirst = std::any_of(r.summary.findings.begin(), r.summary.findings.end(), [](const LintFinding& f) { return f.line == 1; });
const bool onSplit = std::any_of(r.summary.findings.begin(), r.summary.findings.end(), [](const LintFinding& f) { return f.line == 2; });
Check(onFirst, "enum-class: single-line plain enum reported");
Check(onSplit, "enum-class: line-split plain enum reported at the keyword");
}
// const-local's mutation analysis is exact for scalars: assignment, ++/--,
// address-of and binding to a non-const reference are the only ways to
// write one, and all four are tracked. Both directions matter — a missed
// write means advising const on something that cannot be const.
{
RuleRun r = RunRule("void Mutate(int& out);\n"
"void ReadOnly(const int& in);\n"
"void ByValue(int v);\n"
"int Compute();\n"
"void F() {\n"
" int neverWritten = 1;\n"
" int assigned = 1; assigned = 2;\n"
" int incremented = 1; ++incremented;\n"
" int compound = 1; compound += 2;\n"
" int addressed = 1; int* taken = &addressed;\n"
" int toMutatingRef = 1; Mutate(toMutatingRef);\n"
" int toConstRef = 1; ReadOnly(toConstRef);\n"
" int toByValue = 1; ByValue(toByValue);\n"
" int boundToRef = 1; int& alias = boundToRef;\n"
" for (int loop = 0; loop < 1; ++loop) { (void)loop; }\n"
" (void)taken; (void)alias;\n"
"}\n",
"const-local", LintMode::Report);
Check(HasFinding(r.summary, "'neverWritten'"), "const-local: an unwritten local is reported");
Check(HasFinding(r.summary, "'toConstRef'"), "const-local: passing to a const& is not a write");
Check(HasFinding(r.summary, "'toByValue'"), "const-local: passing by value is not a write");
Check(!HasFinding(r.summary, "'assigned'"), "const-local: assignment is a write");
Check(!HasFinding(r.summary, "'incremented'"), "const-local: ++ is a write");
Check(!HasFinding(r.summary, "'compound'"), "const-local: += is a write");
Check(!HasFinding(r.summary, "'addressed'"), "const-local: taking an address counts as a write");
Check(!HasFinding(r.summary, "'toMutatingRef'"), "const-local: binding to a non-const& parameter is a write");
Check(!HasFinding(r.summary, "'boundToRef'"), "const-local: binding to a non-const& local is a write");
Check(!HasFinding(r.summary, "'loop'"), "const-local: a mutated loop counter is not reported");
// Pointers and range-for bindings are excluded: `T* const p` and
// `for (T* const x : …)` are not spellings anybody writes.
Check(!HasFinding(r.summary, "'taken'"), "const-local: pointer locals are out of scope");
}
{
RuleRun r = RunRule("void F() {\n" " for (int each : Range()) { (void)each; }\n" "}\n", "const-local", LintMode::Report);
Check(!HasFinding(r.summary, "'each'"), "const-local: a range-for binding is not reported");
}
// constexpr-constant only promotes a constant whose initialiser is made of
// literals and operators, so a call result is left alone.
{
RuleRun r = RunRule("int Compute();\n"
"void F() {\n"
" const int literal = 4;\n"
" const int folded = 1 << 4;\n"
" const int fromCall = Compute();\n"
" constexpr int already = 8;\n"
" (void)literal; (void)folded; (void)fromCall; (void)already;\n"
"}\n",
"constexpr-constant", LintMode::Report);
Check(HasFinding(r.summary, "'literal'"), "constexpr: a literal constant is reported");
Check(HasFinding(r.summary, "'folded'"), "constexpr: an operator fold over literals is reported");
Check(!HasFinding(r.summary, "'fromCall'"), "constexpr: a call result is not a constant expression");
Check(!HasFinding(r.summary, "'already'"), "constexpr: an existing constexpr is not re-reported");
}
if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", Failures);
return 1;