2026-07-23 01:24:42 +02:00
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std ;
import Crafter . Build ;
# include "../../lint-rules.h"
namespace fs = std : : filesystem ;
using namespace Crafter ;
// Validates the house-style ruleset in lint-rules.h — the transforms and
// reports this repo (and sibling repos that copy the header) rely on. Each
// case writes a scratch file, runs ONE rule via the glob filter, and asserts
// the findings and/or rewritten bytes.
namespace {
std : : int32_t Failures = 0 ;
void Check ( bool cond , std : : string_view msg ) {
if ( ! cond ) {
std : : println ( std : : cerr , " FAIL: {} " , msg ) ;
+ + Failures ;
}
}
struct RuleRun {
LintSummary summary ;
std : : string text ; // file content after the run
} ;
RuleRun RunRule ( std : : string_view content , std : : string_view rule , LintMode mode ) {
static std : : int32_t Counter = 0 ;
fs : : path dir = fs : : temp_directory_path ( ) / " crafter-build-house-rules " / std : : format ( " case-{} " , Counter + + ) ;
fs : : remove_all ( dir ) ;
fs : : create_directories ( dir ) ;
{
std : : ofstream f ( dir / " f.cpp " , std : : ios : : binary | std : : ios : : trunc ) ;
f . write ( content . data ( ) , static_cast < std : : streamsize > ( content . size ( ) ) ) ;
}
Configuration cfg ;
cfg . path = dir ;
cfg . name = " house-fixture " ;
cfg . outputName = " house-fixture " ;
cfg . target = HostTarget ( ) ;
std : : array < fs : : path , 0 > ifaces = { } ;
std : : array < fs : : path , 1 > impls = { " f " } ;
cfg . GetInterfacesAndImplementations ( ifaces , impls ) ;
ProjectLint : : AddProjectLintRules ( cfg ) ;
RunLintOptions opts ;
opts . mode = mode ;
opts . globs = { std : : string ( rule ) } ;
RuleRun run ;
run . summary = RunLint ( cfg , opts ) ;
std : : ifstream f ( dir / " f.cpp " , std : : ios : : binary ) ;
std : : stringstream buffer ;
buffer < < f . rdbuf ( ) ;
run . text = std : : move ( buffer ) . str ( ) ;
return run ;
}
bool HasFinding ( const LintSummary & s , std : : string_view fragment ) {
return std : : any_of ( s . findings . begin ( ) , s . findings . end ( ) , [ & ] ( const LintFinding & f ) { return f . message . contains ( fragment ) ; } ) ;
}
}
int main ( ) {
// fixed-width-types: int/long long convert; main/argc and extern "C" stay.
{
RuleRun r = RunRule ( " int Foo(long long v) { int x = 5; return x; } \n " " extern \" C \" int setenv(const char* n, const char* v, int o); \n " " int main(int argc, char** argv) { return 0; } \n " , " fixed-width-types " , LintMode : : Apply ) ;
Check ( r . text . contains ( " std::int32_t Foo(std::int64_t v) { std::int32_t x = 5; " ) , " fixed-width converts int and long long " ) ;
Check ( r . text . contains ( " extern \" C \" int setenv " ) , " extern \" C \" prototype keeps int " ) ;
Check ( r . text . contains ( " int main(int argc " ) , " main/argc keep int " ) ;
}
// fixed-width-types is signed/unsigned aware, in any specifier order;
// bare char (text) and long double (floating) are not integers.
{
RuleRun r = RunRule ( " void F() { \n "
" unsigned a = 1; \n "
" unsigned int b = 2; \n "
" unsigned long long c = 3; \n "
" long unsigned int d = 4; \n "
" unsigned short e = 5; \n "
" signed f = 6; \n "
" signed char g = 7; \n "
" auto h = static_cast<unsigned char>(g); \n "
" char text = 'x'; \n "
" long double pi = 3.14L; \n "
" } \n " ,
" fixed-width-types " , LintMode : : Apply ) ;
Check ( r . text . contains ( " std::uint32_t a = 1; " ) , " bare unsigned -> uint32 " ) ;
Check ( r . text . contains ( " std::uint32_t b = 2; " ) , " unsigned int -> uint32 " ) ;
Check ( r . text . contains ( " std::uint64_t c = 3; " ) , " unsigned long long -> uint64 " ) ;
Check ( r . text . contains ( " std::uint64_t d = 4; " ) , " long unsigned int (reordered) -> uint64 " ) ;
Check ( r . text . contains ( " std::uint16_t e = 5; " ) , " unsigned short -> uint16 " ) ;
Check ( r . text . contains ( " std::int32_t f = 6; " ) , " bare signed -> int32 " ) ;
Check ( r . text . contains ( " std::int8_t g = 7; " ) , " signed char -> int8 " ) ;
Check ( r . text . contains ( " static_cast<std::uint8_t>(g) " ) , " unsigned char -> uint8 " ) ;
Check ( r . text . contains ( " char text = 'x'; " ) , " bare char stays (text, not an integer) " ) ;
Check ( r . text . contains ( " long double pi = 3.14L; " ) , " long double stays (floating type) " ) ;
}
// brace-style: Allman brace joins; standalone scope block stays.
{
RuleRun r = RunRule ( " void Foo() \n { \n } \n \n void Bar() { \n Baz(); \n { \n Qux(); \n } \n } \n " , " brace-style " , LintMode : : Apply ) ;
Check ( r . text . contains ( " void Foo() { " ) , " Allman brace joined onto header " ) ;
Check ( r . text . contains ( " Baz(); \n { " ) , " scope block brace untouched " ) ;
}
// if-single-line: short body joins; braced body stays.
{
RuleRun r = RunRule ( " void F(bool b) { \n if (b) \n Run(); \n if (b) { \n Run(); \n } \n } \n " , " if-single-line " , LintMode : : Apply ) ;
Check ( r . text . contains ( " if (b) Run(); " ) , " single-statement if body joined " ) ;
Check ( r . text . contains ( " if (b) { " ) , " braced if body untouched " ) ;
}
refactor(lint): single-declaration splits on the AST, per-declarator type
Tokens are not enough for this one, which is worth stating because it is the
opposite of the enum-class case. Splitting a multi-declarator statement by
copying the shared type prefix is wrong in C++:
int* a, b; -> int* a; int* b; // b was int, not int*
Only per-declarator types get it right, and clang has already resolved them —
`int *` for a, plain `int` for b. A token-based splitter cannot know.
The regex it replaces bailed on `*`, `&`, `<>`, parens and quotes, so pointers,
templates and call initialisers were all left alone. All three split now, and
the fixture proves the mixed pointer case above comes out correctly.
Groups are found structurally rather than by matching a line shape: the first
declarator's extent starts at the shared type, so begin < nameOffset, while a
continuation declarator's starts at its own name, so begin == nameOffset. That
signal comes from the AST itself. nameOffset is now on LintDecl, which is also
what lets the replacement reuse each declarator's original text verbatim
instead of reconstructing it.
Replacing a byte range rather than rewriting whole lines means a comment after
the ';' is outside the edit and survives — the line-based version refused to
touch any line carrying a comment. A comment INSIDE the statement still bails,
since the rewrite would swallow it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:35:52 +02:00
// single-declaration splits on the AST, so each declarator carries its own
// resolved type. That is the whole reason it is not a token rule: copying
// the head's type prefix turns `int* a, b;` into `int* a; int* b;` and
// silently changes b from int to int*.
2026-07-23 01:24:42 +02:00
{
refactor(lint): single-declaration splits on the AST, per-declarator type
Tokens are not enough for this one, which is worth stating because it is the
opposite of the enum-class case. Splitting a multi-declarator statement by
copying the shared type prefix is wrong in C++:
int* a, b; -> int* a; int* b; // b was int, not int*
Only per-declarator types get it right, and clang has already resolved them —
`int *` for a, plain `int` for b. A token-based splitter cannot know.
The regex it replaces bailed on `*`, `&`, `<>`, parens and quotes, so pointers,
templates and call initialisers were all left alone. All three split now, and
the fixture proves the mixed pointer case above comes out correctly.
Groups are found structurally rather than by matching a line shape: the first
declarator's extent starts at the shared type, so begin < nameOffset, while a
continuation declarator's starts at its own name, so begin == nameOffset. That
signal comes from the AST itself. nameOffset is now on LintDecl, which is also
what lets the replacement reuse each declarator's original text verbatim
instead of reconstructing it.
Replacing a byte range rather than rewriting whole lines means a comment after
the ';' is outside the edit and survives — the line-based version refused to
touch any line carrying a comment. A comment INSIDE the statement still bails,
since the rewrite would swallow it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:35:52 +02:00
RuleRun r = RunRule ( " #include <vector> \n "
" void F() { \n "
" bool a = false, b = true; \n "
" int* ptrA = nullptr, *ptrB = nullptr; \n "
" int* mixed = nullptr, plain = 7; \n "
" std::vector<int> tmplA{}, tmplB{}; \n "
" int callA = g(), callB = h(); \n "
" int keep = 1, kept = 2; // trailing comment \n "
" } \n " ,
" single-declaration " , LintMode : : Apply ) ;
Check ( r . text . contains ( " bool a = false; \n bool b = true; " ) , " single-declaration: simple split " ) ;
Check ( r . text . contains ( " int* ptrA = nullptr; \n int* ptrB = nullptr; " ) , " single-declaration: pointers split, star kept on the type " ) ;
// The case a token-based splitter gets wrong.
Check ( r . text . contains ( " int* mixed = nullptr; \n int plain = 7; " ) , " single-declaration: only the starred declarator is a pointer " ) ;
Check ( r . text . contains ( " std::vector<int> tmplA{}; \n std::vector<int> tmplB{}; " ) , " single-declaration: template arguments are not a bail-out " ) ;
Check ( r . text . contains ( " int callA = g(); \n int callB = h(); " ) , " single-declaration: call initialisers are not a bail-out " ) ;
// A comment after the ';' is outside the replaced range, so it survives;
// the line-based version refused the whole line instead.
Check ( r . text . contains ( " int keep = 1; \n int kept = 2; // trailing comment " ) , " single-declaration: trailing comment survives the split " ) ;
RuleRun again = RunRule ( r . text , " single-declaration " , LintMode : : Apply ) ;
Check ( again . summary . changedFiles . empty ( ) , " single-declaration: idempotent " ) ;
2026-07-23 01:24:42 +02:00
}
// wrap-join: short wrapped call joins; operator chain joins; long stays.
{
RuleRun r = RunRule ( " void F() { \n G(alpha, \n beta); \n bool x = alpha \n && beta; \n } \n " , " wrap-join " , LintMode : : Apply ) ;
Check ( r . text . contains ( " G(alpha, beta); " ) , " wrapped call arguments joined " ) ;
Check ( r . text . contains ( " bool x = alpha && beta; " ) , " operator continuation joined " ) ;
}
// format-concat: literal-adjacent + rewrites; += RHS rewrites; args survive.
{
RuleRun r = RunRule ( " void F(std::string name, std::string cmd) { \n "
" std::string a = name + \" .cpp \" ; \n "
" std::string b = \" pre- \" + name + \" -post \" ; \n "
" cmd += \" \" + name; \n "
" std::string keep = name + cmd; \n "
" fs::path p; \n "
" std::string c = p.stem().string() + \" .pcm \" ; \n "
" fs::path d = std::string(cmd) + \" .so \" ; \n "
" } \n " ,
" format-concat " , LintMode : : Apply ) ;
Check ( r . text . contains ( " std::string a = std::format( \" {}.cpp \" , name); " ) , " trailing literal converts " ) ;
Check ( ! r . text . contains ( " namestd::format " ) , " replacement splices at the operand boundary " ) ;
Check ( r . text . contains ( " std::string b = std::format( \" pre-{}-post \" , name); " ) , " sandwich chain converts " ) ;
Check ( r . text . contains ( " cmd += std::format( \" {} \" , name); " ) , " += RHS converts " ) ;
Check ( r . text . contains ( " std::string keep = name + cmd; " ) , " literal-free + is left alone " ) ;
Check ( r . text . contains ( " std::string c = std::format( \" {}.pcm \" , p.stem().string()); " ) , " call-chain left operand consumed whole (the stringstd regression) " ) ;
Check ( r . text . contains ( " fs::path d = std::format( \" {}.so \" , std::string(cmd)); " ) , " constructor-call left operand consumed whole " ) ;
}
{
// Ternary and raw-string lines are reported, never rewritten.
RuleRun r = RunRule ( " std::string F(bool b, std::string n) { return b ? n + \" .x \" : n; } \n " , " format-concat " , LintMode : : Report ) ;
Check ( HasFinding ( r . summary , " not auto-fixable " ) , " ternary concat reports instead of fixing " ) ;
Check ( r . text . contains ( " n + \" .x \" " ) , " ternary concat untouched on disk " ) ;
}
// paren-spacing: single-space padding removed; wrapped call ends keep.
{
RuleRun r = RunRule ( " void F() { \n G( x ); \n } \n " , " paren-spacing " , LintMode : : Apply ) ;
Check ( r . text . contains ( " G(x); " ) , " paren padding removed " ) ;
}
// naming: wrong-case function/type/static/global flagged; camel local passes.
{
RuleRun r = RunRule ( " namespace { \n "
" std::int32_t bad_global = 0; \n "
" struct lint_thing {}; \n "
" } \n "
" void lower_func() { \n "
" static bool lowerStatic = false; \n "
" std::int32_t fineLocal = 1; \n "
" } \n " ,
" naming " , LintMode : : Report ) ;
Check ( HasFinding ( r . summary , " 'bad_global' should be PascalCase " ) , " lowercase global flagged " ) ;
Check ( HasFinding ( r . summary , " 'lint_thing' should be PascalCase " ) , " lowercase type flagged " ) ;
Check ( HasFinding ( r . summary , " 'lower_func' should be PascalCase " ) , " lowercase function flagged " ) ;
Check ( HasFinding ( r . summary , " 'lowerStatic' should be PascalCase " ) , " camel static flagged " ) ;
Check ( ! HasFinding ( r . summary , " fineLocal " ) , " camelCase local passes " ) ;
}
{
// Statement calls with inline lambda arguments are NOT function
// definitions (the any_of regression); a genuine lowercase one-liner
// method still is.
RuleRun r = RunRule ( " struct Holder { \n "
" bool clean() const { return true; } \n "
" }; \n "
" void T(std::vector<std::int32_t>& v) { \n "
" bool x = std::any_of(v.begin(), v.end(), [](std::int32_t n) { return n > 0; }); \n "
" std::erase_if(v, [](std::int32_t n) { return n < 0; }); \n "
" } \n " ,
" naming " , LintMode : : Report ) ;
Check ( ! HasFinding ( r . summary , " any_of " ) , " call with lambda argument is not a function definition " ) ;
Check ( ! HasFinding ( r . summary , " erase_if " ) , " bare statement call is not a function definition " ) ;
Check ( HasFinding ( r . summary , " 'clean' should be PascalCase " ) , " lowercase one-liner method still flagged " ) ;
}
// enum-class + no-iostream-print reports.
{
RuleRun r = RunRule ( " enum Color { Red }; \n " , " enum-class " , LintMode : : Report ) ;
Check ( HasFinding ( r . summary , " enum class " ) , " plain enum flagged " ) ;
}
{
RuleRun r = RunRule ( " void F() { std::cout << 1; } \n " , " no-iostream-print " , LintMode : : Report ) ;
Check ( HasFinding ( r . summary , " std::println " ) , " std::cout flagged " ) ;
}
// The whole ruleset is idempotent: a second Apply changes nothing.
{
std : : string_view source = " int Foo() \n { \n std::string s = std::string( \" a \" ) + \" b \" ; \n if (true) \n return 1; \n return 0; \n } \n " ;
static constexpr std : : string_view AllRules = " * " ;
RuleRun first = RunRule ( source , AllRules , LintMode : : Apply ) ;
RuleRun second = RunRule ( first . text , AllRules , LintMode : : Apply ) ;
Check ( second . summary . changedFiles . empty ( ) , " second apply of the full ruleset is a no-op " ) ;
Check ( first . text ! = source , " first apply actually changed the fixture " ) ;
}
// Suppression directives against house transforms: the driver reverts
// line-preserving edits (fixed-width) and the count-changing rules check
// Suppressed() themselves (wrap-join).
{
RuleRun r = RunRule ( " void F() { \n "
" // lint-disable-next-line fixed-width-types \n "
" int keep = 1; \n "
" int convert = 2; \n "
" } \n " ,
" fixed-width-types " , LintMode : : Apply ) ;
Check ( r . text . contains ( " int keep = 1; " ) , " next-line directive keeps the suppressed int " ) ;
Check ( r . text . contains ( " std::int32_t convert = 2; " ) , " unsuppressed line still converts " ) ;
}
{
RuleRun r = RunRule ( " void F() { \n "
" // lint-disable-next-line wrap-join \n "
" G(alpha, \n "
" beta); \n "
" H(alpha, \n "
" beta); \n "
" } \n " ,
" wrap-join " , LintMode : : Apply ) ;
Check ( r . text . contains ( " G(alpha, \n " ) , " suppressed wrap stays wrapped " ) ;
Check ( r . text . contains ( " H(alpha, beta); " ) , " unsuppressed wrap still joins " ) ;
}
// wrap-join converges in ONE run: joining the inner paren wrap balances
// the && line, which only then becomes an operator-joinable continuation.
{
RuleRun r = RunRule ( " void F() { \n "
" bool ok = alpha \n "
" && beta(gamma, \n "
" delta); \n "
" } \n " ,
" wrap-join " , LintMode : : Apply ) ;
Check ( r . text . contains ( " bool ok = alpha && beta(gamma, delta); " ) , " self-enabling joins reach the fixpoint in one apply " ) ;
RuleRun second = RunRule ( r . text , " wrap-join " , LintMode : : Apply ) ;
Check ( second . summary . changedFiles . empty ( ) , " wrap-join is idempotent after the fixpoint " ) ;
}
fix(lint): make the reflow guards token-accurate instead of textual
The transforms guard themselves against comments and raw strings before
joining or rewriting a line, because pulling text up past a `//` buries it
and reflowing a multi-line literal changes the string. Those guards were
substring probes over the raw line, so they answered the wrong question:
Line(n).contains("//") fires on // inside a string literal
Line(n).contains("R\"") fires on the characters R" inside a literal, and
MISSES a raw string opened on an earlier line
Both misfire on this repo's own sources. "MARKER" ends in R", and any string
mentioning a lint-disable directive contains //. Two wrapped call sites in
tests/Lint were being left unjoined for exactly these reasons; they join now,
and the results are in this commit.
Replaced by LineHasComment() (added with the token layer) and a new
LineHasMultiLineToken(), which reports whether any token actually covering
that line spans a line boundary — a raw string or a block comment. Backed by
a per-line bitmap derived from the token cache and invalidated with it.
The guards themselves stay: joining across a real comment or a real
multi-line literal is still unsafe, and there are tests for both. What
changes is that they now fire on comments and literals rather than on the
characters that spell them.
format-concat gets narrower as a result. It used to refuse any line
containing R" and ask for a manual fix; now only a literal that genuinely
spans lines does that, because a single-line raw string reduces to R"…" in
the stripped view, fails the plain-literal test, and travels through as an
argument with its spelling intact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:08:06 +02:00
// The guards used to be substring probes over the raw line, so a literal
// whose TEXT contained `//` or `R"` looked like a comment or a raw string
// and silently disabled the rule. Both shapes occur in this repo's own
// sources — "MARKER" ends in the characters R", and any string mentioning
// a lint-disable directive contains //.
{
RuleRun r = RunRule ( " void F() { \n " " auto hit = text.find( \" MARKER \" , \n " " start); \n " " } \n " , " wrap-join " , LintMode : : Apply ) ;
Check ( r . text . contains ( " text.find( \" MARKER \" , start); " ) , " R \" inside a literal no longer blocks wrap-join " ) ;
}
{
RuleRun r = RunRule ( " void F() { \n " " Check(read() == \" // lint-disable-next-line trim \\ n \" , \n " " \" message \" ); \n " " } \n " , " wrap-join " , LintMode : : Apply ) ;
Check ( r . text . contains ( " \\ n \" , \" message \" ); " ) , " // inside a literal no longer blocks wrap-join " ) ;
}
// A real trailing comment still blocks the join: text pulled up past a
// `//` would be swallowed by it.
{
RuleRun r = RunRule ( " void F() { \n " " auto v = g(alpha, // why \n " " beta); \n " " } \n " , " wrap-join " , LintMode : : Apply ) ;
Check ( r . text . contains ( " g(alpha, // why \n " ) , " a real comment still blocks wrap-join " ) ;
}
// A genuinely multi-line literal is never reflowed — joining its lines
// would change the string's contents.
{
std : : string_view source = " void F() { \n "
" auto text = R \" sql(SELECT a, \n "
" b FROM t)sql \" ; \n "
" } \n " ;
RuleRun r = RunRule ( source , " wrap-join " , LintMode : : Apply ) ;
Check ( r . text = = source , " wrap-join leaves a multi-line raw string alone " ) ;
RuleRun paren = RunRule ( source , " paren-spacing " , LintMode : : Apply ) ;
Check ( paren . text = = source , " paren-spacing leaves a multi-line raw string alone " ) ;
}
// Type keywords inside a literal are text, not declarations.
{
std : : string_view source = " void F() { \n "
" auto sql = R \" q(int x; unsigned long y;)q \" ; \n "
" } \n " ;
RuleRun r = RunRule ( source , " fixed-width-types " , LintMode : : Apply ) ;
Check ( r . text = = source , " fixed-width-types leaves type names inside a raw string alone " ) ;
}
feat(lint): no-char-pointer reads the AST, retiring the interop denylist
The rule was `\bchar\s*\*` over the text minus a substring denylist — argv,
getenv, setenv, dlerror, c_str, .data(, reinterpret_cast, extern ". Every entry
was a patch for one interop site, the list could only grow as libraries
arrived, and each entry disabled the rule for the whole LINE it appeared on.
It now walks declarations and asks the question the denylist was approximating:
whose header dictates this spelling? A declaration with C language linkage, or
one whose initialiser binds to an entity declared outside the project root, is
somebody else's API and keeps its spelling. getenv, c_str and friends are
exempt because of where they are declared, not because they are named here, so
a new external library needs no new entry.
Two bugs found while testing this, both of which had made the rule silently
pass over the entire repository:
clang_getCursorLanguage cannot be used to detect extern "C". Its default answer
is CXLanguage_C for a plain function, variable or parameter even in a C++
translation unit, so isExternC was true almost everywhere and exempted
everything. Replaced by tracking CXCursor_LinkageSpec depth during the walk,
reading the extent text to tell extern "C" from extern "C++".
Attributing any foreign reference in a subtree to the enclosing declaration was
too broad: a function that merely touched libc++ somewhere in its body would
exempt its own signature. Narrowed to initialiser contexts — a variable, field
or parameter — which is where a binding to a foreign API actually occurs.
Also: functions now carry their RESULT type rather than the whole function
type, since the parameters arrive as their own declarations and would otherwise
be reported twice. main's parameters are exempt structurally, its signature
being fixed by the language rather than chosen here.
Two sites keep an explicit lint-disable, both Crafter::Run taking main's argv
verbatim. That is two visible, reasoned suppressions in place of a denylist
that silently disabled the rule for every line mentioning one of eight tokens.
ExternalCloneDir and ExternalIncludeFlags are now exposed from :External, so a
source that includes an external dependency's headers can be parsed without
running a build to discover where they are. BuildExternal derives its own
working directory through the same function, so the two cannot drift.
Crafter.Build-Shader.cpp needed this to parse at all.
A file with no compile command — project.cpp, which LoadProject builds with its
own flags — is not a translation unit of the build graph, so AST rules skip it
the way a rule self-filters by extension. That is distinct from a file that
should have parsed and did not, which stays an error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:46:32 +02:00
// no-char-pointer reads the AST, so it distinguishes OUR char* from one
// whose spelling belongs to somebody else's header. This is what replaced
// the substring denylist (argv, getenv, c_str, reinterpret_cast, …): each
// entry there disabled the rule for a whole line, and the list could only
// grow as new libraries arrived.
{
RuleRun r = RunRule ( " #include <cstdlib> \n "
" #include <string> \n "
" extern \" C \" const char* CApiEntry(const char* path); \n "
" char* OurBadApi(char* input) { return input; } \n "
" void F() { \n "
" char* fromLibc = std::getenv( \" HOME \" ); \n "
" std::string mine = \" ok \" ; \n "
" const char* toLibc = mine.c_str(); \n "
" auto raw = reinterpret_cast<char*>(&mine); \n "
" } \n " ,
" no-char-pointer " , LintMode : : Report ) ;
// Ours, so reported: the declaration and its parameter.
Check ( HasFinding ( r . summary , " 'OurBadApi' " ) , " no-char-pointer: our own char* return is reported " ) ;
Check ( HasFinding ( r . summary , " 'input' " ) , " no-char-pointer: our own char* parameter is reported " ) ;
// Foreign, so exempt — each for a reason, not by name.
Check ( ! HasFinding ( r . summary , " 'CApiEntry' " ) , " no-char-pointer: extern \" C \" declaration is exempt " ) ;
Check ( ! HasFinding ( r . summary , " 'path' " ) , " no-char-pointer: extern \" C \" parameter is exempt " ) ;
Check ( ! HasFinding ( r . summary , " 'fromLibc' " ) , " no-char-pointer: a value from libc is exempt " ) ;
Check ( ! HasFinding ( r . summary , " 'toLibc' " ) , " no-char-pointer: a value from c_str() is exempt " ) ;
// A local whose deduced type is char* is still our declaration, so it
// is reported. The old denylist exempted every line mentioning
// reinterpret_cast; a deliberate low-level cast now takes an explicit
// lint-disable comment, which is at least visible at the site.
Check ( HasFinding ( r . summary , " 'raw' " ) , " no-char-pointer: a deduced char* local is still ours " ) ;
}
feat(lint): naming reads the AST, deleting the scope heuristic
The rule was 125 lines: four std::regex, a hand-rolled {/} scope stack, a
cumulative paren-depth counter so a wrapped parameter list would not look like
a declaration, a 40-entry keyword denylist, and a "function-shaped line" guess
whose own comment conceded it was heuristic. Storage class came from
lineStr.contains("static "). It is now ~55 lines that ask clang what kind of
declaration each thing is and what encloses it.
The three regressions the old version carried special cases for — a call with
an inline lambda argument, a bare statement call, a one-liner method — need no
handling at all, because a call is not a declaration. Their tests pass
unchanged.
On this repository the exact version found 42 violations the heuristic had
never been able to see, all real:
- 37 members of the libclang function-pointer table added two commits ago
were PascalCase. The old varDecl regex could not match a declaration whose
type is decltype(&f), so they were silently skipped. Renamed to camelCase,
which mirrors clang_createIndex -> createIndex more closely anyway.
- Crafter.Build-Shader.cpp had a snake_case local, file_name_list, invisible
to the heuristic for the same reason (it declares a const char* array).
- Four extern "C" declarations of libc functions in tests were reported as
badly-named functions. Those are named by the C library, so C language
linkage is now an exemption — the same principled test no-char-pointer
uses, rather than another denylist entry.
New tests cover what the line-based version structurally could not reach: a
signature wrapped over several lines, `static` on its own line above the
declaration it applies to, and a member versus a local inside a method.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:26 +02:00
// Cases the line-based heuristic structurally could not see. It only looked
// at declarations starting at cumulative paren depth 0 and inferred scope
// from a running {/} count, so a wrapped signature, a specifier split over
// two lines, or a local shadowing a member all escaped it.
{
RuleRun r = RunRule ( " #include <string> \n "
" namespace Outer { \n "
" int wrapped_function( \n "
" int first, \n "
" int second) { return first + second; } \n "
" static \n "
" int splitStatic = 1; \n "
" struct Holder { \n "
" int Member = 0; \n "
" void Method() { \n "
" int Local = 1; \n "
" (void)Local; \n "
" } \n "
" }; \n "
" } \n "
" extern \" C \" int c_api_entry(const char* name); \n " ,
" naming " , LintMode : : Report ) ;
// A signature wrapped over lines is still a function declaration.
Check ( HasFinding ( r . summary , " 'wrapped_function' should be PascalCase " ) , " naming: wrapped signature is seen " ) ;
// `static` on its own line still applies to the declaration below it.
Check ( HasFinding ( r . summary , " 'splitStatic' should be PascalCase " ) , " naming: split specifier is seen " ) ;
// Members are camelCase; the enclosing kind decides, not a brace count.
Check ( HasFinding ( r . summary , " 'Member' should be camelCase " ) , " naming: member is checked as a member " ) ;
// A local inside a method is a local, not a member and not a global.
Check ( HasFinding ( r . summary , " 'Local' should be camelCase " ) , " naming: local inside a method is a local " ) ;
// Named by libc, not by us.
Check ( ! HasFinding ( r . summary , " c_api_entry " ) , " naming: extern \" C \" declaration is exempt " ) ;
Check ( ! HasFinding ( r . summary , " 'Method' " ) , " naming: PascalCase method passes " ) ;
Check ( ! HasFinding ( r . summary , " 'Holder' " ) , " naming: PascalCase type passes " ) ;
}
feat(lint): fixed-width-types keeps widths that a foreign API chose
The rewrite itself stays token-shaped — it edits type SPELLINGS, which an AST
discards — but what it must not touch now comes from the AST.
The old exemption was per LINE: `int main`, `argc`, `argv`, `extern "`. Being a
transform, a missed exemption here does not over-report, it emits code that no
longer matches the API being called, so this is the rule where guessing from
substrings mattered most. And being per-line, it also disabled the rule for
anything sharing a line with one of those words.
Now a declaration with C language linkage, or one whose initialiser binds to an
entity declared outside the project, contributes a protected byte range and
keeps its spelling. That answers the case directly: a function declared in
somebody else's header taking `unsigned int` keeps `unsigned int`, and a local
initialised from strtoul keeps `unsigned long`, because of where those are
declared rather than because of what the line says.
main is protected from the start of its declaration to the opening brace of its
body, not for its whole extent. Its signature is fixed by the language; its body
is ordinary code. Three findings on this repository came out of that
distinction, all correct:
for (int i = 1; i < argc; ++i) -> for (std::int32_t i = 1; ...)
skipped before only because `argc` appeared on the line, plus Crafter::Run's own
`int argc` and return type, which are ours rather than the language's.
All three AST rules now share one interop test instead of carrying a denylist
each, and it is the same test: whose header dictates this spelling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:59:46 +02:00
// fixed-width-types must not rewrite an integer whose width somebody else's
// header chose — the rewrite would leave code that no longer matches the API
// it calls. Derived from the AST, so the exemption is per-declaration rather
// than the old per-line textual one, which both missed cases and disabled
// the rule for anything else sharing a line with `argc`/`extern "`.
{
RuleRun r = RunRule ( " #include <cstdlib> \n "
" extern \" C \" unsigned long CApiCall(unsigned int flags); \n "
" long OurOwnApi(int value) { return value; } \n "
" void F() { \n "
" unsigned long fromLibc = std::strtoul( \" 1 \" , nullptr, 10); \n "
" unsigned ours = 1; \n "
" (void)fromLibc; (void)ours; \n "
" } \n "
" int main(int argc, char** argv) { \n "
" for (int i = 0; i < argc; ++i) { (void)argv[i]; } \n "
" return 0; \n "
" } \n " ,
" fixed-width-types " , LintMode : : Apply ) ;
// Somebody else's widths, kept.
Check ( r . text . contains ( " extern \" C \" unsigned long CApiCall(unsigned int flags); " ) , " fixed-width: extern \" C \" signature keeps its widths " ) ;
Check ( r . text . contains ( " unsigned long fromLibc = std::strtoul " ) , " fixed-width: a value from a C library keeps its width " ) ;
Check ( r . text . contains ( " int main(int argc, char** argv) " ) , " fixed-width: main's signature is untouched " ) ;
// Ours, converted.
Check ( r . text . contains ( " std::int64_t OurOwnApi(std::int32_t value) " ) , " fixed-width: our own signature converts " ) ;
Check ( r . text . contains ( " std::uint32_t ours = 1; " ) , " fixed-width: our own local converts " ) ;
// The loop counter inside main's BODY is ordinary code. The old rule
// skipped it because `argc` appeared on the same line; only the
// signature is exempt now, not everything near it.
Check ( r . text . contains ( " for (std::int32_t i = 0; " ) , " fixed-width: main's body is not exempt, only its signature " ) ;
}
2026-07-31 00:21:12 +02:00
// enum-class asks for the next TOKEN after `enum`, so a declaration split
// over lines reads the same as one that is not. The regex it replaced
// required the name to follow `enum` on the same line.
{
RuleRun r = RunRule ( " enum Plain { A }; \n "
" enum \n "
" Split { B }; \n "
" enum class Scoped { C }; \n "
" enum \n "
" class SplitScoped { D }; \n "
" 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 ( ) ) ) ;
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>
2026-07-31 00:50:48 +02:00
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 ; } ) ;
2026-07-31 00:21:12 +02:00
Check ( onFirst , " enum-class: single-line plain enum reported " ) ;
Check ( onSplit , " enum-class: line-split plain enum reported at the keyword " ) ;
}
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>
2026-07-31 00:50:48 +02:00
// 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 " ) ;
}
2026-07-31 02:05:37 +02:00
// constexpr-constant asks clang's constant evaluator, not the tokens of the
// initialiser. sizeof and a fold over other constants are constant
// expressions that no token scan can recognise; a call result is not.
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>
2026-07-31 00:50:48 +02:00
{
RuleRun r = RunRule ( " int Compute(); \n "
2026-07-31 02:05:37 +02:00
" constexpr int Base = 4; \n "
" struct Big { int a, b; }; \n "
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>
2026-07-31 00:50:48 +02:00
" void F() { \n "
" const int literal = 4; \n "
" const int folded = 1 << 4; \n "
2026-07-31 02:05:37 +02:00
" const int fromSizeof = sizeof(Big); \n "
" const int fromOtherConstant = Base + 1; \n "
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>
2026-07-31 00:50:48 +02:00
" const int fromCall = Compute(); \n "
" constexpr int already = 8; \n "
2026-07-31 02:05:37 +02:00
" (void)literal; (void)folded; (void)fromSizeof; \n "
" (void)fromOtherConstant; (void)fromCall; (void)already; \n "
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>
2026-07-31 00:50:48 +02:00
" } \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 " ) ;
2026-07-31 02:05:37 +02:00
// Neither of these is reachable from a token scan of the initialiser.
Check ( HasFinding ( r . summary , " 'fromSizeof' " ) , " constexpr: sizeof folds " ) ;
Check ( HasFinding ( r . summary , " 'fromOtherConstant' " ) , " constexpr: a fold over another constant folds " ) ;
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>
2026-07-31 00:50:48 +02:00
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 " ) ;
}
2026-07-23 01:24:42 +02:00
if ( Failures > 0 ) {
std : : println ( std : : cerr , " {} assertions failed " , Failures ) ;
return 1 ;
}
return 0 ;
}