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

@ -64,7 +64,7 @@ int main() {
fs::path projectFile = root / "project.cpp";
std::ofstream(projectFile) << "\n";
fs::path cwdBin = fs::current_path() / "bin";
bool cwdBinExisted = fs::exists(cwdBin);
const bool cwdBinExisted = fs::exists(cwdBin);
CleanProject(projectFile);
Check(!fs::exists(root / "bin"), "the named project's bin/ is gone");

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;

View file

@ -54,7 +54,7 @@ namespace {
out.reserve(ctx.content.size());
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
std::string_view line = ctx.Line(n);
bool crlf = line.ends_with('\r');
const bool crlf = line.ends_with('\r');
if (crlf) line.remove_suffix(1);
while (line.ends_with(' ') || line.ends_with('\t')) line.remove_suffix(1);
out += line;
@ -226,7 +226,7 @@ int main() {
LintSummary sum = RunLint(cfg, Mode(LintMode::Apply));
Check(s.Read("a") == "hello\nworld\n", "Apply rewrites the file");
Check(sum.changedFiles.size() == 1, "one file formatted");
bool hasNote = std::any_of(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.rule == "note"; });
const bool hasNote = std::any_of(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.rule == "note"; });
Check(hasNote, "report-only findings still recorded in Apply mode");
Check(sum.errors == 0, "clean apply has no errors");
}
@ -259,8 +259,8 @@ int main() {
ctx.SetContent(std::move(c));
});
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
bool one = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.rule == "one"; });
bool two = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.rule == "two"; });
const bool one = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.rule == "one"; });
const bool two = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.rule == "two"; });
Check(one && two, "chained transforms both attributed");
Check(s.Read("a") == "AAA\n", "Report leaves chain input untouched");
LintSummary app = RunLint(cfg, Mode(LintMode::Apply));
@ -281,7 +281,7 @@ int main() {
Check(s.Read("a") == "keep\n", "throwing transform reverted, disk untouched");
Check(sum.errors == 1, "exception counted as error");
Check(sum.changedFiles.empty(), "reverted transform is not a change");
bool threw = std::any_of(sum.findings.begin(), sum.findings.end(),
const bool threw = std::any_of(sum.findings.begin(), sum.findings.end(),
[](const LintFinding& f) {
return f.line == 0 && f.message.contains("mid-transform");
});
@ -301,7 +301,7 @@ int main() {
}
});
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
bool wholeFile = std::any_of(rep.findings.begin(), rep.findings.end(),
const bool wholeFile = std::any_of(rep.findings.begin(), rep.findings.end(),
[](const LintFinding& f) {
return f.rule == "final-newline" && f.line == 0;
});
@ -323,8 +323,8 @@ int main() {
ctx.SetContent(std::move(c));
});
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
bool flagged = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.message == "flagged"; });
bool reformat = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.message == "would reformat"; });
const bool flagged = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.message == "flagged"; });
const bool reformat = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.message == "would reformat"; });
Check(flagged && reformat, "mixed rule records both finding kinds");
RunLint(cfg, Mode(LintMode::Apply));
Check(s.Read("a") == "bad\n", "mixed rule's transform applied");
@ -389,10 +389,10 @@ int main() {
});
AddTrimRule(cfg);
LintSummary sum = RunLint(cfg, Mode(LintMode::Report));
bool line2Silent = std::none_of(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.line == 2 && f.rule != "trim"; });
bool line3Loud = std::count_if(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.line == 3; }) >= 2;
const bool line2Silent = std::none_of(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.line == 2 && f.rule != "trim"; });
const bool line3Loud = std::count_if(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.line == 3; }) >= 2;
Check(line2Silent, "both named rules suppressed on the target line");
bool trimStillFires = std::any_of(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.line == 2 && f.rule == "trim"; });
const bool trimStillFires = std::any_of(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.line == 2 && f.rule == "trim"; });
Check(trimStillFires, "unnamed rule still fires on the target line");
Check(line3Loud, "unsuppressed line reports from both rules");
}
@ -419,7 +419,7 @@ int main() {
AddTrimRule(cfg);
cfg.AddLintRule("flag", [](LintContext& ctx) { ctx.Report(2, "bad"); });
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
bool onlyTrim = !rep.findings.empty() && std::all_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.rule == "trim"; });
const bool onlyTrim = !rep.findings.empty() && std::all_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.rule == "trim"; });
Check(onlyTrim, "file-level rule directive kills that rule, trim still fires");
RunLint(cfg, Mode(LintMode::Apply));
Check(s.Read("a") == "// lint-disable-file flag\nbad\n", "other rules still format");
@ -458,12 +458,12 @@ int main() {
Check(offsetsSound, "tokens: every offset/length addresses real bytes");
// Ordered by offset, so binary search in TokensOnLine is valid.
bool ordered = std::ranges::is_sorted(toks, {}, &LintToken::offset);
const bool ordered = std::ranges::is_sorted(toks, {}, &LintToken::offset);
Check(ordered, "tokens: stream is in source order");
// Inactive #ifdef branch is still lexed — this is what keeps token
// rules covering every platform, unlike an AST.
bool sawHidden = std::ranges::any_of(toks, [&](const LintToken& t) {
const bool sawHidden = std::ranges::any_of(toks, [&](const LintToken& t) {
return t.kind == LintTokenKind::Identifier && ctx.TokenText(t) == "HiddenBranch";
});
Check(sawHidden, "tokens: inactive #ifdef branch is lexed");