Crafter.Build/implementations/Crafter.Build-Lint.cpp

1162 lines
56 KiB
C++
Raw Normal View History

2026-07-23 01:24:42 +02:00
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
#include <clang-c/Index.h>
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
#include <windows.h>
#else
#include <dlfcn.h>
#endif
2026-07-23 01:24:42 +02:00
export module Crafter.Build:Lint_impl;
import std;
import :Lint;
import :Clang;
import :Platform;
import :Progress;
namespace fs = std::filesystem;
using namespace Crafter;
namespace {
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
// ---------------- libclang ----------------
//
// libclang is loaded at runtime rather than linked. Linking -lclang would
// break the mingw and MSVC cross-builds at link time and would put a
// libclang.so.NN runtime dependency into the otherwise self-contained
// release tarballs; the clang-c header is used for its declarations only,
// and every call goes through a pointer resolved here. Failure to load is
// a hard error surfaced once by RunLint — there is deliberately no second,
// weaker lexer to fall back to, because two engines disagreeing about what
// is a comment is a worse failure than not running.
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
using LibHandle = HMODULE;
LibHandle OpenLibrary(const std::string& name) { return LoadLibraryA(name.c_str()); }
void* LibrarySymbol(LibHandle handle, const std::string& name) { return reinterpret_cast<void*>(GetProcAddress(handle, name.c_str())); }
constexpr std::string_view LibClangNames[] = {"libclang.dll", "clang.dll"};
#else
using LibHandle = void*;
LibHandle OpenLibrary(const std::string& name) { return dlopen(name.c_str(), RTLD_NOW | RTLD_LOCAL); }
void* LibrarySymbol(LibHandle handle, const std::string& name) { return dlsym(handle, name.c_str()); }
constexpr std::string_view LibClangNames[] = {
"libclang.so", "libclang.so.22.1", "libclang.so.21.1", "libclang.so.20.1",
"libclang.so.1", "libclang.dylib",
};
#endif
// Signatures come from decltype on the header's declarations, so they can
// never drift from the real API. decltype is unevaluated, so naming the
// functions here does not create a link-time reference to them.
struct LibClang {
LibHandle handle = nullptr;
std::string error; // non-empty exactly when handle is null
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
decltype(&clang_createIndex) createIndex = nullptr;
decltype(&clang_disposeIndex) disposeIndex = nullptr;
decltype(&clang_parseTranslationUnit) parseTranslationUnit = nullptr;
decltype(&clang_disposeTranslationUnit) disposeTranslationUnit = nullptr;
decltype(&clang_getFile) getFile = nullptr;
decltype(&clang_getLocationForOffset) getLocationForOffset = nullptr;
decltype(&clang_getRange) getRange = nullptr;
decltype(&clang_getRangeStart) getRangeStart = nullptr;
decltype(&clang_getRangeEnd) getRangeEnd = nullptr;
decltype(&clang_getFileLocation) getFileLocation = nullptr;
decltype(&clang_tokenize) tokenize = nullptr;
decltype(&clang_disposeTokens) disposeTokens = nullptr;
decltype(&clang_getTokenKind) getTokenKind = nullptr;
decltype(&clang_getTokenExtent) getTokenExtent = nullptr;
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
// AST layer.
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
decltype(&clang_getTranslationUnitCursor) getTranslationUnitCursor = nullptr;
decltype(&clang_visitChildren) visitChildren = nullptr;
decltype(&clang_getCursorKind) getCursorKind = nullptr;
decltype(&clang_getCursorSpelling) getCursorSpelling = nullptr;
decltype(&clang_getCursorType) getCursorType = nullptr;
decltype(&clang_getTypeSpelling) getTypeSpelling = nullptr;
decltype(&clang_getCursorLocation) getCursorLocation = nullptr;
decltype(&clang_getCursorExtent) getCursorExtent = nullptr;
decltype(&clang_getCursorReferenced) getCursorReferenced = nullptr;
decltype(&clang_Cursor_getStorageClass) getStorageClass = nullptr;
decltype(&clang_EnumDecl_isScoped) enumDeclIsScoped = nullptr;
decltype(&clang_isCursorDefinition) isCursorDefinition = nullptr;
decltype(&clang_Location_isFromMainFile) locationIsFromMainFile = nullptr;
decltype(&clang_getNumDiagnostics) getNumDiagnostics = nullptr;
decltype(&clang_getDiagnostic) getDiagnostic = nullptr;
decltype(&clang_getDiagnosticSeverity) getDiagnosticSeverity = nullptr;
decltype(&clang_getDiagnosticSpelling) getDiagnosticSpelling = nullptr;
decltype(&clang_disposeDiagnostic) disposeDiagnostic = nullptr;
decltype(&clang_getCString) getCString = nullptr;
decltype(&clang_disposeString) disposeString = nullptr;
decltype(&clang_getFileName) getFileName = nullptr;
decltype(&clang_Cursor_isNull) cursorIsNull = nullptr;
decltype(&clang_getResultType) getResultType = nullptr;
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
};
LibClang LoadLibClang() {
LibClang lib;
std::vector<std::string> tried;
// CRAFTER_BUILD_LIBCLANG pins an exact path, mirroring the LIBCXX_DIR /
// CRAFTER_MINGW_DIR overrides used elsewhere. It is exclusive: pointing
// it at a broken path must fail loudly rather than quietly succeed with
// some other libclang, or the override is useless for diagnosing which
// library is actually in play.
std::vector<std::string> candidates;
if (const char* pinned = std::getenv("CRAFTER_BUILD_LIBCLANG"); pinned && *pinned) {
candidates.emplace_back(pinned);
} else {
for (std::string_view name : LibClangNames) candidates.emplace_back(name);
}
auto join = [](const std::vector<std::string>& parts) {
std::string joined;
for (const std::string& part : parts) {
if (!joined.empty()) joined += ", ";
joined += part;
}
return joined;
};
for (const std::string& name : candidates) {
lib.handle = OpenLibrary(name);
if (lib.handle) break;
tried.push_back(name);
}
if (!lib.handle) {
lib.error = std::format("could not load libclang (tried {}); install clang, or point CRAFTER_BUILD_LIBCLANG at it", join(tried));
return lib;
}
std::vector<std::string> missing;
auto bind = [&](auto& slot, const std::string& name) {
slot = reinterpret_cast<std::remove_reference_t<decltype(slot)>>(LibrarySymbol(lib.handle, name));
if (!slot) missing.push_back(name);
};
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
bind(lib.createIndex, "clang_createIndex");
bind(lib.disposeIndex, "clang_disposeIndex");
bind(lib.parseTranslationUnit, "clang_parseTranslationUnit");
bind(lib.disposeTranslationUnit, "clang_disposeTranslationUnit");
bind(lib.getFile, "clang_getFile");
bind(lib.getLocationForOffset, "clang_getLocationForOffset");
bind(lib.getRange, "clang_getRange");
bind(lib.getRangeStart, "clang_getRangeStart");
bind(lib.getRangeEnd, "clang_getRangeEnd");
bind(lib.getFileLocation, "clang_getFileLocation");
bind(lib.tokenize, "clang_tokenize");
bind(lib.disposeTokens, "clang_disposeTokens");
bind(lib.getTokenKind, "clang_getTokenKind");
bind(lib.getTokenExtent, "clang_getTokenExtent");
bind(lib.getTranslationUnitCursor, "clang_getTranslationUnitCursor");
bind(lib.visitChildren, "clang_visitChildren");
bind(lib.getCursorKind, "clang_getCursorKind");
bind(lib.getCursorSpelling, "clang_getCursorSpelling");
bind(lib.getCursorType, "clang_getCursorType");
bind(lib.getTypeSpelling, "clang_getTypeSpelling");
bind(lib.getCursorLocation, "clang_getCursorLocation");
bind(lib.getCursorExtent, "clang_getCursorExtent");
bind(lib.getCursorReferenced, "clang_getCursorReferenced");
bind(lib.getStorageClass, "clang_Cursor_getStorageClass");
bind(lib.enumDeclIsScoped, "clang_EnumDecl_isScoped");
bind(lib.isCursorDefinition, "clang_isCursorDefinition");
bind(lib.locationIsFromMainFile, "clang_Location_isFromMainFile");
bind(lib.getNumDiagnostics, "clang_getNumDiagnostics");
bind(lib.getDiagnostic, "clang_getDiagnostic");
bind(lib.getDiagnosticSeverity, "clang_getDiagnosticSeverity");
bind(lib.getDiagnosticSpelling, "clang_getDiagnosticSpelling");
bind(lib.disposeDiagnostic, "clang_disposeDiagnostic");
bind(lib.getCString, "clang_getCString");
bind(lib.disposeString, "clang_disposeString");
bind(lib.getFileName, "clang_getFileName");
bind(lib.cursorIsNull, "clang_Cursor_isNull");
bind(lib.getResultType, "clang_getResultType");
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
if (!missing.empty()) {
lib.handle = nullptr;
lib.error = std::format("loaded {} but it is missing {}", candidates.front(), join(missing));
}
return lib;
}
const LibClang& Clang() {
static const LibClang Lib = LoadLibClang();
return Lib;
}
// The -x language for a source file, or empty when we must not lex it.
// .cppm needs c++-module explicitly: libclang does not infer a module unit
// from the extension and silently treats every flag as a linker input if
// left to guess. Shaders and data files return empty — lexing GLSL as C++
// yields plausible-looking nonsense.
std::string_view LexLanguage(const fs::path& file) {
std::string ext = file.extension().string();
if (ext == ".cppm" || ext == ".ixx") return "c++-module";
if (ext == ".cpp" || ext == ".cc" || ext == ".cxx" || ext == ".h" || ext == ".hpp" || ext == ".cu") return "c++";
if (ext == ".c") return "c";
return {};
}
LintTokenKind MapTokenKind(CXTokenKind kind) {
switch (kind) {
case CXToken_Punctuation: return LintTokenKind::Punctuation;
case CXToken_Keyword: return LintTokenKind::Keyword;
case CXToken_Identifier: return LintTokenKind::Identifier;
case CXToken_Literal: return LintTokenKind::Literal;
case CXToken_Comment: return LintTokenKind::Comment;
}
return LintTokenKind::Punctuation;
}
// Lex `content` as if it were `file`, returning tokens in source order.
//
// The buffer is handed over as an unsaved file, so a transform's in-memory
// edits are what get lexed — never the stale bytes on disk. The parse is
// expected to fail (a module unit's `import std;` cannot resolve without
// PCMs, and we deliberately do not supply the build's flags here); that
// does not matter, because clang_tokenize re-lexes the buffer and lexing
// has no semantic prerequisites. SingleFileParse keeps it from chasing
// #includes it does not need.
std::vector<LintToken> LexFile(const fs::path& file, const std::string& content) {
std::string_view language = LexLanguage(file);
if (language.empty()) return {};
const LibClang& lc = Clang();
if (!lc.handle) return {};
std::string path = file.string();
std::string languageArg = std::format("-x{}", language);
std::string standardArg = language == "c" ? "-std=c23" : "-std=c++26";
// clang's argv is char* by contract; keep the raw pointers confined to
// this call rather than letting them into any signature of ours.
std::array<const char*, 4> args{languageArg.c_str(), standardArg.c_str(), "-ferror-limit=0", "-w"};
CXUnsavedFile unsaved{};
unsaved.Filename = path.c_str();
unsaved.Contents = content.data();
unsaved.Length = static_cast<std::uint32_t>(content.size());
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
CXIndex index = lc.createIndex(0, 0);
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
if (!index) return {};
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
CXTranslationUnit tu = lc.parseTranslationUnit(index, path.c_str(), args.data(), static_cast<std::int32_t>(args.size()), &unsaved, 1, CXTranslationUnit_SingleFileParse | CXTranslationUnit_SkipFunctionBodies | CXTranslationUnit_KeepGoing);
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
if (!tu) {
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
lc.disposeIndex(index);
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
return {};
}
std::vector<LintToken> tokens;
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
if (CXFile cxFile = lc.getFile(tu, path.c_str())) {
CXSourceRange whole = lc.getRange(lc.getLocationForOffset(tu, cxFile, 0), lc.getLocationForOffset(tu, cxFile, static_cast<std::uint32_t>(content.size())));
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
CXToken* raw = nullptr;
std::uint32_t count = 0;
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
lc.tokenize(tu, whole, &raw, &count);
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
tokens.reserve(count);
for (std::uint32_t i = 0; i < count; ++i) {
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
CXSourceRange extent = lc.getTokenExtent(tu, raw[i]);
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
std::uint32_t line = 0;
std::uint32_t column = 0;
std::uint32_t begin = 0;
std::uint32_t end = 0;
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
lc.getFileLocation(lc.getRangeStart(extent), nullptr, &line, &column, &begin);
lc.getFileLocation(lc.getRangeEnd(extent), nullptr, nullptr, nullptr, &end);
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
if (end < begin || begin > content.size()) continue;
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
tokens.push_back({MapTokenKind(lc.getTokenKind(raw[i])), begin, std::min<std::size_t>(end - begin, content.size() - begin), line, column});
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
}
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
if (raw) lc.disposeTokens(tu, raw, count);
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
}
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
lc.disposeTranslationUnit(tu);
lc.disposeIndex(index);
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
return tokens;
}
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
// ---------------- AST ----------------
std::string TakeString(const LibClang& lc, CXString s) {
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
// const char* because clang_getCString returns one. no-char-pointer
// works this out for itself now: the initialiser resolves to a
// declaration in clang-c/, outside the project, so the declaration is
// flagged as foreign API and exempt. No suppression comment needed.
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
const char* raw = lc.getCString(s);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
std::string out = raw ? raw : "";
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
lc.disposeString(s);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
return out;
}
// Overwrite every `export` keyword with spaces, leaving `export module`
// alone. Byte-length preserving, so every line and column libclang reports
// still lands on the original file.
//
// libclang has no CXCursorKind for a C++20 export declaration: it reports
// CXCursor_UnexposedDecl and does not descend, so `export namespace X { … }`
// collapses to one childless node and every declaration inside it becomes
// invisible. Five of this repo's interfaces are written that way, which is
// 677 lines including Configuration and LintContext. A plain `namespace X`
// IS descended into, so removing the keyword is enough — the declarations
// stop being exported in this parse, which is irrelevant to the names,
// kinds, types and scopes the rules ask about.
//
// The braced `export { … }` form would need the braces kept, which this
// does not attempt; it does not occur here, and it would show up as a parse
// error rather than silently wrong output.
std::string BlankExportKeywords(const std::string& content, std::span<const LintToken> tokens) {
std::string out = content;
for (std::size_t i = 0; i < tokens.size(); ++i) {
const LintToken& token = tokens[i];
if (token.kind != LintTokenKind::Keyword && token.kind != LintTokenKind::Identifier) continue;
if (token.length != 6 || out.compare(token.offset, 6, "export") != 0) continue;
// `export module Crafter.Build:Lint;` must survive: without it the
// unit stops being a module interface and its own partition
// imports become ill-formed.
if (i + 1 < tokens.size() && content.compare(tokens[i + 1].offset, 6, "module") == 0) continue;
out.replace(token.offset, 6, " ");
}
return out;
}
LintDeclKind MapCursorKind(CXCursorKind kind) {
switch (kind) {
case CXCursor_Namespace: return LintDeclKind::Namespace;
case CXCursor_ClassDecl:
case CXCursor_ClassTemplate: return LintDeclKind::Class;
case CXCursor_StructDecl: return LintDeclKind::Struct;
case CXCursor_UnionDecl: return LintDeclKind::Union;
case CXCursor_EnumDecl: return LintDeclKind::Enum;
case CXCursor_EnumConstantDecl: return LintDeclKind::EnumConstant;
case CXCursor_TypedefDecl:
case CXCursor_TypeAliasDecl:
case CXCursor_TypeAliasTemplateDecl: return LintDeclKind::TypeAlias;
case CXCursor_FunctionDecl:
case CXCursor_FunctionTemplate: return LintDeclKind::Function;
case CXCursor_CXXMethod: return LintDeclKind::Method;
case CXCursor_Constructor: return LintDeclKind::Constructor;
case CXCursor_Destructor: return LintDeclKind::Destructor;
case CXCursor_FieldDecl: return LintDeclKind::Field;
case CXCursor_VarDecl: return LintDeclKind::Variable;
case CXCursor_ParmDecl: return LintDeclKind::Parameter;
default: return LintDeclKind::Other;
}
}
// Split a shell command string into argv for libclang, dropping argv[0]
// and undoing the \" escaping the shell form needs. Whitespace-separated:
// the build assembles and runs this very string through a shell, so a path
// containing a space is already unsupported upstream of here.
std::vector<std::string> CommandToArgs(std::string_view command) {
std::vector<std::string> args;
for (std::size_t i = 0; i < command.size();) {
while (i < command.size() && command[i] == ' ') ++i;
std::size_t begin = i;
while (i < command.size() && command[i] != ' ') ++i;
if (i > begin) args.emplace_back(command.substr(begin, i - begin));
}
if (!args.empty()) args.erase(args.begin()); // argv[0] is the compiler
for (std::string& arg : args) {
// Only \" unescapes. Erasing every backslash would destroy the
// Windows include paths GetBaseCommand puts on the command line.
std::string unescaped;
unescaped.reserve(arg.size());
for (std::size_t i = 0; i < arg.size(); ++i) {
if (arg[i] == '\\' && i + 1 < arg.size() && arg[i + 1] == '"') continue;
unescaped += arg[i];
}
arg = std::move(unescaped);
}
return args;
}
struct DeclWalk {
const LibClang* lc = nullptr;
const std::string* content = nullptr;
const fs::path* projectRoot = nullptr;
std::vector<LintDecl>* out = nullptr;
std::vector<std::size_t> stack; // indices of the enclosing declarations
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
std::int32_t externCDepth = 0; // inside how many extern "C" blocks
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
};
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
// 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.
bool IsExternCLinkage(std::string_view text) {
std::size_t quote = text.find('"');
if (quote == std::string_view::npos) return false;
std::size_t close = text.find('"', quote + 1);
if (close == std::string_view::npos) return false;
return text.substr(quote + 1, close - quote - 1) == "C";
}
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
bool PathInsideRoot(const fs::path& p, const fs::path& root);
// True when `cursor` names something declared outside the project — a
// system header, libc++, an external dependency. This is what makes the
// interop exemption principled rather than a list of names: the question
// asked is "whose header dictates this spelling", and the answer comes
// from where the declaration actually lives.
bool ResolvesOutsideProject(const LibClang& lc, CXCursor cursor, const fs::path& projectRoot) {
if (projectRoot.empty()) return false;
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
CXCursor target = lc.getCursorReferenced(cursor);
if (lc.cursorIsNull(target)) return false;
CXSourceLocation location = lc.getCursorLocation(target);
if (lc.locationIsFromMainFile(location)) return false;
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
CXFile file = nullptr;
std::uint32_t line = 0;
std::uint32_t column = 0;
std::uint32_t offset = 0;
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
lc.getFileLocation(location, &file, &line, &column, &offset);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
if (!file) return false;
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
std::string path = TakeString(lc, lc.getFileName(file));
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
if (path.empty()) return false;
return !PathInsideRoot(fs::path(path), projectRoot);
}
CXChildVisitResult VisitDecl(CXCursor cursor, CXCursor, CXClientData data) {
DeclWalk& walk = *static_cast<DeclWalk*>(data);
const LibClang& lc = *walk.lc;
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
CXSourceLocation location = lc.getCursorLocation(cursor);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
// 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.
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
if (!lc.locationIsFromMainFile(location)) return CXChildVisit_Continue;
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
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
CXCursorKind kind = lc.getCursorKind(cursor);
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
LintDeclKind mapped = MapCursorKind(kind);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
if (mapped == LintDeclKind::Other) {
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
// A foreign reference inside a VARIABLE, FIELD or PARAMETER is an
// initialiser binding that declaration to somebody else's API —
// `char* p = getenv(...)`. Deliberately not applied when the
// enclosing declaration is a function: a function that merely
// touches libc++ somewhere in its body would otherwise exempt its
// 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;
if (initialiserContext && ResolvesOutsideProject(lc, cursor, *walk.projectRoot)) {
enclosing.isForeignApi = true;
}
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
}
// Recursed by hand rather than with CXChildVisit_Recurse so the
// enclosing-declaration stack stays accurate: the callback is never
// told when a subtree ends.
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
if (kind == CXCursor_LinkageSpec) {
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
CXSourceRange extent = lc.getCursorExtent(cursor);
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
std::uint32_t specBegin = 0;
std::uint32_t specEnd = 0;
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
lc.getFileLocation(lc.getRangeStart(extent), nullptr, nullptr, nullptr, &specBegin);
lc.getFileLocation(lc.getRangeEnd(extent), nullptr, nullptr, nullptr, &specEnd);
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
std::string_view text;
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);
if (isC) ++walk.externCDepth;
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
lc.visitChildren(cursor, &VisitDecl, &walk);
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
if (isC) --walk.externCDepth;
return CXChildVisit_Continue;
}
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
lc.visitChildren(cursor, &VisitDecl, &walk);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
return CXChildVisit_Continue;
}
LintDecl decl;
decl.kind = mapped;
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
decl.name = TakeString(lc, lc.getCursorSpelling(cursor));
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
// For anything callable, `type` is the RESULT type rather than the
// 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;
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
decl.type = TakeString(lc, lc.getTypeSpelling(callable ? lc.getResultType(lc.getCursorType(cursor)) : lc.getCursorType(cursor)));
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
CXFile nameFile = nullptr;
std::uint32_t nameLine = 0;
std::uint32_t nameColumn = 0;
std::uint32_t nameOffset = 0;
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
lc.getFileLocation(location, &nameFile, &nameLine, &nameColumn, &nameOffset);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
decl.line = nameLine;
decl.column = nameColumn;
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
CXSourceRange extent = lc.getCursorExtent(cursor);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
std::uint32_t begin = 0;
std::uint32_t end = 0;
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
lc.getFileLocation(lc.getRangeStart(extent), nullptr, nullptr, nullptr, &begin);
lc.getFileLocation(lc.getRangeEnd(extent), nullptr, nullptr, nullptr, &end);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
decl.begin = begin;
decl.end = std::min<std::size_t>(end, walk.content->size());
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
decl.isDefinition = lc.isCursorDefinition(cursor) != 0;
decl.isStatic = lc.getStorageClass(cursor) == CX_SC_Static;
decl.isScopedEnum = mapped == LintDeclKind::Enum && lc.enumDeclIsScoped(cursor) != 0;
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
// Inside an extern "C" block, where a C API's spelling is not ours to
// modernise. NOT clang_getCursorLanguage: its default answer for a
// plain function, variable or parameter is CXLanguage_C even in a C++
// translation unit, so trusting it exempted essentially everything.
decl.isExternC = walk.externCDepth > 0;
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
// libclang exposes no constexpr query. The keyword can only appear in
// this declaration's own specifier list, i.e. between the start of its
// extent and its name, so a search bounded to that span is exact rather
// than the line-wide `contains("constexpr ")` it replaces.
if (nameOffset > decl.begin && decl.begin < walk.content->size()) {
std::string_view specifiers(walk.content->data() + decl.begin, std::min<std::size_t>(nameOffset - decl.begin, walk.content->size() - decl.begin));
decl.isConstexpr = specifiers.contains("constexpr");
}
decl.parent = walk.stack.empty() ? LintNoParent : walk.stack.back();
walk.out->push_back(std::move(decl));
walk.stack.push_back(walk.out->size() - 1);
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
lc.visitChildren(cursor, &VisitDecl, &walk);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
walk.stack.pop_back();
return CXChildVisit_Continue;
}
std::vector<LintDecl> WalkDecls(const LibClang& lc, CXTranslationUnit tu, const std::string& content, const fs::path& projectRoot) {
std::vector<LintDecl> decls;
DeclWalk walk;
walk.lc = &lc;
walk.content = &content;
walk.projectRoot = &projectRoot;
walk.out = &decls;
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
lc.visitChildren(lc.getTranslationUnitCursor(tu), &VisitDecl, &walk);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
// A declaration on an interop boundary makes its parameters and fields
// interop too — the exemption has to cover the whole signature, not
// just the node that happened to name the foreign entity.
for (LintDecl& decl : decls) {
if (decl.parent == LintNoParent) continue;
const LintDecl& parent = decls[decl.parent];
if (parent.isForeignApi) decl.isForeignApi = true;
if (parent.isExternC) decl.isExternC = true;
}
return decls;
}
// libclang locates its builtin headers relative to its own install path,
// which need not agree with the clang++ on PATH that produced the PCMs.
// When it disagrees the failure is total and unhelpful — every parse dies
// on "'stddef.h' file not found" — so ask the driver and pass it
// explicitly. Cached like HostTarget, and for the same reason.
const std::string& ClangResourceDir() {
static const std::string Cached = []() -> std::string {
CommandResult r = RunCommandChecked("clang++ -print-resource-dir");
if (r.exitCode != 0) return {};
std::string out = std::move(r.output);
while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back();
return out;
}();
return Cached;
}
struct AstResult {
std::vector<LintDecl> decls;
std::string error; // empty exactly on success
};
// Parse `content` as `file` with the flags that actually built its PCMs.
//
// Unlike the tokenizer this cannot tolerate a failed parse: a fatal
// diagnostic leaves a fragment, and a fragment is indistinguishable from a
// file that declares nothing. So a fatal is returned as an error for the
// driver to surface, never as an empty declaration list.
AstResult ParseAst(const fs::path& file, const std::string& content, std::string_view compileCommand, const fs::path& projectRoot, std::span<const LintToken> tokens) {
AstResult result;
std::string_view language = LexLanguage(file);
if (language.empty() || language == "c") {
result.error = std::format("{} is not a C++ translation unit", file.filename().string());
return result;
}
if (compileCommand.empty()) {
result.error = "no compile command is known for this file";
return result;
}
const LibClang& lc = Clang();
if (!lc.handle) {
result.error = lc.error;
return result;
}
// Byte-length preserving, so cursor line/column land on the original.
std::string buffer = BlankExportKeywords(content, tokens);
std::string path = file.string();
std::vector<std::string> args = CommandToArgs(compileCommand);
// Required: libclang will not infer a module interface unit from the
// .cppm extension, and silently treats every flag as a linker input if
// left to guess.
args.push_back(std::format("-x{}", language));
if (!ClangResourceDir().empty()) args.push_back(std::format("-resource-dir={}", ClangResourceDir()));
std::vector<const char*> argv;
argv.reserve(args.size());
for (const std::string& arg : args) argv.push_back(arg.c_str());
CXUnsavedFile unsaved{};
unsaved.Filename = path.c_str();
unsaved.Contents = buffer.data();
unsaved.Length = static_cast<std::uint32_t>(buffer.size());
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
CXIndex index = lc.createIndex(0, 0);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
if (!index) {
result.error = "clang_createIndex failed";
return result;
}
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
CXTranslationUnit tu = lc.parseTranslationUnit(index, path.c_str(), argv.data(), static_cast<std::int32_t>(argv.size()), &unsaved, 1, CXTranslationUnit_None);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
if (!tu) {
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
lc.disposeIndex(index);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
result.error = "clang could not create a translation unit";
return result;
}
std::string fatal;
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
std::uint32_t diagnostics = lc.getNumDiagnostics(tu);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
for (std::uint32_t i = 0; i < diagnostics && fatal.empty(); ++i) {
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
CXDiagnostic diagnostic = lc.getDiagnostic(tu, i);
if (lc.getDiagnosticSeverity(diagnostic) == CXDiagnostic_Fatal) {
fatal = TakeString(lc, lc.getDiagnosticSpelling(diagnostic));
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
}
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
lc.disposeDiagnostic(diagnostic);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
}
if (fatal.empty()) {
result.decls = WalkDecls(lc, tu, buffer, projectRoot);
} else {
result.error = std::move(fatal);
}
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
lc.disposeTranslationUnit(tu);
lc.disposeIndex(index);
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
return result;
}
2026-07-27 02:59:11 +02:00
// Blank comments and the bodies of string/character literals to spaces,
// copying '\n' through so byte offsets and line numbers in the result
// match the original text exactly.
//
// Derived from the token stream rather than scanned character by
// character, which is what makes raw strings, escapes, encoding prefixes
// and a literal like '"' come out right. Editing a copy of the buffer in
// place — rather than appending to a fresh string — makes the
// length-preserving property structural instead of something every branch
// has to remember.
std::string StripLiterals(const std::string& content, std::span<const LintToken> tokens) {
std::string out = content;
auto blank = [&out](std::size_t from, std::size_t to) {
for (std::size_t i = from; i < to && i < out.size(); ++i) {
if (out[i] != '\n') out[i] = ' ';
}
};
for (const LintToken& token : tokens) {
std::size_t begin = token.offset;
std::size_t end = token.offset + token.length;
if (token.kind == LintTokenKind::Comment) {
blank(begin, end);
continue;
2026-07-23 01:24:42 +02:00
}
2026-07-27 02:59:11 +02:00
if (token.kind != LintTokenKind::Literal || token.length < 2) continue;
std::string_view text(content.data() + begin, token.length);
// Numeric literals are code and stay; only string and character
// literals have a body to hide. A digit separator makes 1'000 look
// quote-ish, so require everything before the quote to be an
// encoding prefix (L, u, U, u8, R and their combinations).
std::size_t quote = text.find_first_of("\"'");
if (quote == std::string_view::npos) continue;
std::string_view prefix = text.substr(0, quote);
if (!std::ranges::all_of(prefix, [](char c) { return c == 'L' || c == 'u' || c == 'U' || c == '8' || c == 'R'; })) continue;
// Keep the opening quote and the closing one, blank everything
// between. For a raw string that also blanks the R"delim( and
// )delim" scaffolding, leaving exactly two quotes — which is what
// rules counting quotes to find a literal's extent rely on.
blank(begin + quote + 1, end - 1);
2026-07-23 01:24:42 +02:00
}
return out;
}
std::vector<std::string_view> SplitLines(std::string_view content) {
std::vector<std::string_view> lines;
std::size_t start = 0;
while (start <= content.size()) {
std::size_t end = content.find('\n', start);
if (end == std::string_view::npos) {
// Skip a phantom empty final line after a trailing '\n'.
if (start < content.size()) lines.push_back(content.substr(start));
break;
}
lines.push_back(content.substr(start, end - start));
start = end + 1;
}
return lines;
}
bool PathInsideRoot(const fs::path& p, const fs::path& root) {
fs::path rel = fs::weakly_canonical(p).lexically_relative(fs::weakly_canonical(root));
return !rel.empty() && *rel.begin() != "..";
}
// Depth-first walk over the dependency graph keeping only Configurations
// whose path lies inside the project root — GitProject / external deps
// live under the global cache and are foreign code: they contribute
// neither rules nor files. Root-first order so the root's rules win the
// by-name dedup.
std::vector<Configuration*> CollectLocalConfigs(Configuration& root, const fs::path& projectRoot) {
std::vector<Configuration*> local;
std::unordered_set<Configuration*> seen;
std::function<void(Configuration*)> walk = [&](Configuration* c) {
if (!seen.insert(c).second) return;
if (PathInsideRoot(fs::absolute(c->path), projectRoot)) {
local.push_back(c);
}
for (Configuration* dep : c->dependencies) walk(dep);
};
walk(&root);
return local;
}
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
// Maps each source to the Configuration that owns it. std::map keeps the
// deterministic sorted iteration the old std::set gave, and the value is
// what the AST layer needs: PCMs are flag-locked, so a file parsed with a
// sibling configuration's flags does not parse at all. Three regimes are in
// play — the library/executable, each declared test (which carries its own
// target, defines and -march via the march fan-out), and project.cpp.
using SourceOwners = std::map<fs::path, const Configuration*>;
void CollectConfigSources(const Configuration& c, SourceOwners& files) {
// First owner wins, matching the root-first rule dedup: a source
// reachable through two configurations parses with the nearer one.
auto own = [&files, &c](fs::path file) { files.emplace(std::move(file), &c); };
2026-07-23 01:24:42 +02:00
for (const std::unique_ptr<Module>& mod : c.interfaces) {
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
own(fs::path(std::format("{}.cppm", mod->path.string())));
2026-07-23 01:24:42 +02:00
for (const std::unique_ptr<ModulePartition>& part : mod->partitions) {
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
own(fs::path(std::format("{}.cppm", part->path.string())));
2026-07-23 01:24:42 +02:00
}
}
for (const Implementation& impl : c.implementations) {
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
own(fs::path(std::format("{}.cpp", impl.path.string())));
2026-07-23 01:24:42 +02:00
}
// cFiles/cuda resolve against cwd at build time (see Build's compile
// loops); mirror that here.
for (const fs::path& cf : c.cFiles) {
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
own(fs::absolute(fs::path(std::format("{}.c", cf.string()))).lexically_normal());
2026-07-23 01:24:42 +02:00
}
for (const fs::path& cu : c.cuda) {
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
own(fs::absolute(fs::path(std::format("{}.cu", cu.string()))).lexically_normal());
2026-07-23 01:24:42 +02:00
}
for (const Shader& shader : c.shaders) {
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
own(fs::absolute(shader.path).lexically_normal());
2026-07-23 01:24:42 +02:00
}
// files/buildFiles/assets are deliberately excluded: data shipped or
// referenced by the build, not source code.
}
}
std::string LintContext::Extension() const {
return file.extension().string();
}
std::string_view LintContext::Line(std::size_t n) const {
if (n == 0 || n > lines.size()) return {};
return lines[n - 1];
}
const std::string& LintContext::CommentStripped() {
if (!commentStrippedCache) {
2026-07-27 02:59:11 +02:00
commentStrippedCache = StripLiterals(content, Tokens());
2026-07-23 01:24:42 +02:00
}
return *commentStrippedCache;
}
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
std::span<const LintToken> LintContext::Tokens() {
if (!tokenCache) tokenCache = LexFile(file, content);
return *tokenCache;
}
std::string_view LintContext::TokenText(const LintToken& token) const {
if (token.offset >= content.size()) return {};
return std::string_view(content).substr(token.offset, token.length);
}
std::span<const LintToken> LintContext::TokensOnLine(std::size_t line) {
// Tokens come back in source order, so one line's tokens are a contiguous
// run and can be bracketed by binary search.
std::span<const LintToken> all = Tokens();
auto begin = std::ranges::lower_bound(all, line, {}, &LintToken::line);
auto end = std::ranges::upper_bound(all, line, {}, &LintToken::line);
return all.subspan(static_cast<std::size_t>(begin - all.begin()), static_cast<std::size_t>(end - begin));
}
bool LintContext::LineHasComment(std::size_t line) {
std::span<const LintToken> onLine = TokensOnLine(line);
return std::ranges::any_of(onLine, [](const LintToken& t) { return t.kind == LintTokenKind::Comment; });
}
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
bool LintContext::LineHasMultiLineToken(std::size_t line) {
if (!spannedLineCache) {
std::vector<bool> spanned(lines.size(), false);
for (const LintToken& token : Tokens()) {
std::size_t crossed = static_cast<std::size_t>(std::ranges::count(TokenText(token), '\n'));
if (crossed == 0) continue;
for (std::size_t n = token.line; n <= token.line + crossed && n <= spanned.size(); ++n) {
spanned[n - 1] = true;
}
}
spannedLineCache = std::move(spanned);
}
return line >= 1 && line <= spannedLineCache->size() && (*spannedLineCache)[line - 1];
}
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
std::span<const LintDecl> LintContext::Decls() {
if (!declCache) {
AstResult parsed = ParseAst(file, content, compileCommand, projectRoot, Tokens());
astReason = std::move(parsed.error);
declCache = std::move(parsed.decls);
}
return *declCache;
}
bool LintContext::AstAvailable() {
Decls();
return astReason.empty();
}
std::string_view LintContext::AstUnavailableReason() {
Decls();
return astReason;
}
2026-07-23 01:24:42 +02:00
void LintContext::Report(std::size_t line, std::string message) {
sink->push_back({file, line, activeRule, std::move(message)});
}
namespace {
// Scan raw lines for suppression comments. Raw, not stripped: the
// directives ARE comments. A next-line directive on (0-based) line i
// targets 1-based line i + 2 — the line below it.
LintSuppressions ParseSuppressions(std::span<const std::string_view> lines) {
LintSuppressions s;
constexpr std::string_view NextLineMarker = "lint-disable-next-line";
constexpr std::string_view FileMarker = "lint-disable-file";
for (std::size_t i = 0; i < lines.size(); ++i) {
std::size_t slash = lines[i].find("//");
if (slash == std::string_view::npos) continue;
bool nextLine = true;
std::size_t marker = lines[i].find(NextLineMarker, slash);
std::size_t markerLen = NextLineMarker.size();
if (marker == std::string_view::npos) {
nextLine = false;
marker = lines[i].find(FileMarker, slash);
markerLen = FileMarker.size();
}
if (marker == std::string_view::npos) continue;
// Everything after the marker is rule names; none = all rules.
std::string_view rest = lines[i].substr(marker + markerLen);
std::vector<std::string> names;
std::size_t pos = 0;
while (pos < rest.size()) {
if (rest[pos] == ' ' || rest[pos] == '\t' || rest[pos] == ',' || rest[pos] == '\r') { ++pos; continue; }
std::size_t end = rest.find_first_of(" \t,\r", pos);
if (end == std::string_view::npos) end = rest.size();
names.emplace_back(rest.substr(pos, end - pos));
pos = end;
}
if (nextLine) {
if (names.empty()) s.lineAll.insert(i + 2);
else for (std::string& n : names) s.lineRules[i + 2].insert(std::move(n));
} else {
if (names.empty()) s.fileAll = true;
else for (std::string& n : names) s.fileRules.insert(std::move(n));
}
}
return s;
}
}
bool LintContext::Suppressed(std::string_view rule, std::size_t line) {
if (!suppressionsCache) suppressionsCache = ParseSuppressions(lines);
const LintSuppressions& s = *suppressionsCache;
if (s.fileAll || s.fileRules.contains(std::string(rule))) return true;
if (line == 0) return false;
if (s.lineAll.contains(line)) return true;
if (auto it = s.lineRules.find(line); it != s.lineRules.end()) return it->second.contains(std::string(rule));
return false;
}
void LintContext::SetContent(std::string newContent) {
content = std::move(newContent);
lines = SplitLines(content);
commentStrippedCache.reset();
suppressionsCache.reset(); // line numbers may have shifted — re-parse
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
tokenCache.reset(); // offsets refer to the old buffer — re-lex
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
spannedLineCache.reset(); // derived from tokenCache
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
declCache.reset(); // extents refer to the old buffer — re-parse
astReason.clear();
2026-07-23 01:24:42 +02:00
}
void Configuration::AddLintRule(std::string name, std::function<void(LintContext&)> check) {
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
lintRules.push_back({std::move(name), std::move(check), false});
}
void Configuration::AddAstLintRule(std::string name, std::function<void(LintContext&)> check) {
lintRules.push_back({std::move(name), std::move(check), true});
2026-07-23 01:24:42 +02:00
}
LintSummary Crafter::RunLint(Configuration& projectCfg, const RunLintOptions& opts) {
LintSummary summary;
feat(lint): libclang-backed token layer Adds LintContext::Tokens() and friends, backed by clang_tokenize, as the substrate the rules will move onto. Nothing consumes it yet. libclang is dlopen'd rather than linked: -lclang would break the mingw and MSVC cross-builds at link time and would put a libclang.so.NN runtime dependency into the otherwise self-contained release tarballs. The clang-c header is used for its declarations only, and the function-pointer table is typed with decltype so the signatures cannot drift from the real API. Three properties this buys that the hand-rolled scanners could not have: - a raw string literal or block comment is ONE token, so the documented "raw string literals are not recognized" limitation goes away; - `//` inside a literal is not a comment, so LineHasComment() replaces the Line(n).contains("//") probes that false-positive on it; - tokens cover preprocessor branches that are inactive for the host, since clang_tokenize lexes rather than evaluates #if. Token rules therefore keep seeing every platform's code, which an AST could not offer. The parse backing the tokenizer is expected to fail on module units — no PCMs, no build flags — and that is fine, because lexing has no semantic prerequisites. Verified in the new tests. LintSummary::Clean() now counts `errors`. It previously ignored them, so an infrastructure failure that produced no findings reported clean and exited 0; a missing libclang would have been exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:54:38 +02:00
// libclang backs the lexer every rule reads through, so a failed load is
// fatal rather than a downgrade: running the rules without it would report
// against a substrate that disagrees with the one they were written for.
if (const LibClang& lc = Clang(); !lc.handle) {
std::println(std::cerr, "lint: {}", lc.error);
++summary.errors;
return summary;
}
2026-07-23 01:24:42 +02:00
fs::path projectRoot = opts.projectFile.empty()
? fs::absolute(projectCfg.path)
: opts.projectFile.parent_path();
std::vector<Configuration*> localConfigs = CollectLocalConfigs(projectCfg, projectRoot);
// Collect rules root-first, dedup by name (first registration wins).
std::vector<const LintRule*> rules;
std::unordered_set<std::string_view> ruleNames;
for (Configuration* c : localConfigs) {
for (const LintRule& rule : c->lintRules) {
if (ruleNames.insert(rule.name).second) rules.push_back(&rule);
}
}
if (rules.empty()) {
summary.noRulesDefined = true;
std::println(std::cerr,
R"msg(No lint rules defined.
Register rules in project.cpp before returning the Configuration:
cfg.AddLintRule("no-tabs", [](Crafter::LintContext& ctx) {{
if (ctx.Extension() != ".cpp" && ctx.Extension() != ".cppm") return;
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {{
if (ctx.Line(n).contains('\t')) ctx.Report(n, "tab character (use spaces)");
}}
}});
A rule that calls ctx.SetContent(newContent) is a transform: `crafter-build
format` applies it to disk, and `crafter-build lint` reports where it would.
`crafter-build lint` runs every rule over the project's own sources.)msg");
return summary;
}
std::erase_if(rules, [&](const LintRule* r) { return !MatchAny(opts.globs, r->name); });
summary.rulesRun = rules.size();
if (opts.listOnly) {
for (const LintRule* rule : rules) std::println("{}", rule->name);
return summary;
}
if (rules.empty()) {
std::println("No lint rules matched.");
return summary;
}
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
SourceOwners files;
2026-07-23 01:24:42 +02:00
for (Configuration* c : localConfigs) {
CollectConfigSources(*c, files);
for (const Test& t : c->tests) CollectConfigSources(t.config, files);
}
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
// project.cpp is not built by Build() at all — LoadProject compiles it
// against the host PCM cache with its own flags, so it is owned by nothing
// here and gets no compile command. Token rules still cover it.
if (!opts.projectFile.empty()) files.emplace(opts.projectFile, nullptr);
2026-07-23 01:24:42 +02:00
fs::path cwd = fs::current_path();
auto shown = [&cwd](const fs::path& p) {
return PathInsideRoot(p, cwd) ? p.lexically_relative(cwd) : p;
};
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
// Any rule reading Decls() needs this configuration's module PCMs, and a
// parse without them is fatal rather than degraded. Produce them up front
// rather than letting each file fail on its own, and cache the assembled
// command per configuration — GetCompileCommand walks the dependency tree.
const bool anyRuleNeedsAst = std::ranges::any_of(rules, [](const LintRule* r) { return r->needsAst; });
std::unordered_map<const Configuration*, std::string> commands;
if (anyRuleNeedsAst && !opts.noAst) {
for (Configuration* c : localConfigs) {
auto ensure = [&](const Configuration& cfg) {
if (commands.contains(&cfg)) return;
std::string command;
try {
CompileCommand assembled = GetCompileCommand(cfg);
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
// Sources that #include an external dependency's headers —
// Crafter.Build-Shader.cpp and glslang here — need those -I
// flags to parse at all. Build appends its own authoritative
// set after the external build; these come straight from the
// declaration, which is all a parse needs.
command = assembled.command + assembled.externalIncludeFlags;
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
if (!fs::exists(assembled.stdPcmDir/"std.pcm")) {
Progress::Task task(std::format("Building std PCM ({}-{})", cfg.target, cfg.march));
fs::create_directories(assembled.stdPcmDir);
std::string error = BuildStdPcm(cfg, assembled.stdPcmDir/"std.pcm");
if (!error.empty()) command.clear();
}
} catch (const std::exception&) {
command.clear(); // surfaced per file as an AST reason
}
commands.emplace(&cfg, std::move(command));
};
ensure(*c);
for (const Test& t : c->tests) ensure(t.config);
}
}
for (const auto& [file, owner] : files) {
2026-07-23 01:24:42 +02:00
std::ifstream in(file, std::ios::binary);
if (!in) continue; // config parse already read it; a vanished file fails the build first
std::stringstream buffer;
buffer << in.rdbuf();
LintContext ctx;
ctx.file = file;
ctx.content = std::move(buffer).str();
ctx.lines = SplitLines(ctx.content);
ctx.sink = &summary.findings;
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
ctx.projectRoot = projectRoot;
if (auto it = commands.find(owner); it != commands.end()) ctx.compileCommand = it->second;
2026-07-23 01:24:42 +02:00
++summary.filesLinted;
const std::string original = ctx.content;
for (const LintRule* rule : rules) {
ctx.activeRule = rule->name;
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
// A semantic rule that cannot see an AST would report nothing,
// which is indistinguishable from a clean file — and for a
// transform it would mean rewriting without the information that
// decides what is safe to touch. Skip it and make the run fail.
if (rule->needsAst) {
if (opts.noAst) continue;
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 compile command means this file is not a translation unit
// of the build graph — project.cpp, which LoadProject compiles
// with its own flags, or a header. A semantic rule does not
// apply there, the same way a rule self-filters by extension,
// so skip it quietly. That is a different thing from a file we
// SHOULD have been able to parse and could not, which is an
// error: reporting nothing for it would be indistinguishable
// from reporting it clean.
if (ctx.compileCommand.empty()) continue;
feat(lint): AST layer over libclang cursors Adds LintContext::Decls() — clang's view of the declarations written in the file — plus AddAstLintRule to register a rule that reads it. No rule uses it yet; the three that will are migrated separately. The declarations come back as a flat vector with parent indices rather than an opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback hops back into project.so per node, and "is this at namespace scope or inside a function?" becomes an index lookup instead of a hand-rolled brace stack. Three things had to be solved for this to work at all on this codebase. libclang cannot see through `export`. A C++20 export declaration has no CXCursorKind, so `export namespace Crafter { … }` arrives as a childless CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the eleven interfaces here are written that way — 677 lines, including Configuration and LintContext, yielding zero usable cursors. A plain `namespace` IS descended into, so the fix is to blank the keyword before parsing, byte-length preserving so every line and column still lands on the original file. `export module` is left alone or the unit stops being a module interface. Verified end-to-end against a fixture whose asserted line numbers match the unblanked file. PCMs are flag-locked, so each file has to parse with the flags that built it. CollectConfigSources now records which Configuration owns each source instead of flattening to a set, because three regimes coexist: the library, each test (carrying its own target, defines and -march), and project.cpp, which Build never touches and which therefore gets no command at all. libclang resolves its builtin headers relative to its own install path, which need not match the clang++ that wrote the PCMs. When it doesn't, every parse dies on "'stddef.h' file not found", so -resource-dir is passed explicitly from `clang++ -print-resource-dir`. Two flags on each declaration replace what would otherwise become more substring denylists: isExternC, and isForeignApi for a declaration that binds to an entity declared outside the project root — resolved through clang_getCursorReferenced and the same inside-the-root test the dependency walk already uses. Parameters and fields inherit it, so an exemption covers a whole signature rather than the one node that named the foreign entity. Failure is never silent. A fatal diagnostic leaves a fragment that is indistinguishable from a file declaring nothing, so it is reported as an error instead: the rule is skipped, a finding explains why, and summary.errors makes the run fail. --no-ast opts out deliberately and exits normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:30:41 +02:00
if (!ctx.AstAvailable()) {
ctx.Report(0, std::format("rule '{}' needs an AST, which is unavailable: {}", rule->name, ctx.AstUnavailableReason()));
++summary.errors;
continue;
}
}
2026-07-23 01:24:42 +02:00
// Snapshot for transform diffing — and the revert point if the
// rule throws, so a half-applied transform never reaches disk
// and chained rules see clean input.
std::string before = ctx.content;
try {
rule->check(ctx);
} catch (const std::exception& e) {
// Never let a rule's exception unwind across the project
// DLL boundary — surface it as a finding instead.
ctx.SetContent(std::move(before));
ctx.Report(0, std::format("rule '{}' threw: {}", rule->name, e.what()));
++summary.errors;
if (opts.mode != LintMode::Report) {
// Report mode prints it with the findings; the other
// modes don't print findings, so surface it here.
std::println(std::cerr, "{}: rule '{}' threw: {}", shown(file).string(), rule->name, e.what());
}
continue;
}
// Transform detection is compare-by-value: a rule that SetContents
// identical bytes is not a change. The mutated content carries
// forward in every mode so chained rules compose identically
// whether or not this run writes.
if (ctx.content == before) continue;
// File-level suppression disables the transform outright — in
// every mode, so `format` never rewrites a suppressed file.
if (ctx.Suppressed(rule->name, 0)) {
ctx.SetContent(std::move(before));
continue;
}
// Diff before/after. Same line count → per-line handling:
// suppressed changed lines are REVERTED (all modes — suppression
// must also stop `format`), the rest yield would-reformat
// findings in the dry modes. Different count (or no differing
// line — SplitLines hides a trailing '\n', the final-newline
// case) → one whole-file finding; count-changing transforms
// handle per-line suppression themselves (see
// LintContext::Suppressed).
std::vector<std::string_view> beforeLines = SplitLines(before);
bool anyLineDiffers = false;
if (beforeLines.size() == ctx.lines.size()) {
std::vector<std::size_t> reverted;
for (std::size_t i = 0; i < beforeLines.size(); ++i) {
if (beforeLines[i] == ctx.lines[i]) continue;
anyLineDiffers = true;
if (ctx.Suppressed(rule->name, i + 1)) {
reverted.push_back(i);
} else if (opts.mode != LintMode::Apply) {
summary.findings.push_back({file, i + 1, rule->name, "would reformat"});
}
}
if (!reverted.empty()) {
std::string rebuilt;
rebuilt.reserve(ctx.content.size());
std::size_t next = 0;
for (std::size_t i = 0; i < ctx.lines.size(); ++i) {
rebuilt += (next < reverted.size() && reverted[next] == i) ? beforeLines[i] : ctx.lines[i];
if (next < reverted.size() && reverted[next] == i) ++next;
if (i + 1 < ctx.lines.size() || ctx.content.ends_with('\n')) rebuilt += '\n';
}
ctx.SetContent(std::move(rebuilt));
}
}
if (!anyLineDiffers && opts.mode != LintMode::Apply && ctx.content != before) {
summary.findings.push_back({file, 0, rule->name, "would reformat"});
}
}
// Drop findings the file's directives suppress — covers Report()
// calls from any rule (custom ones included) plus the derived
// would-reformat findings above. Line-0 findings only match
// file-level directives.
std::erase_if(summary.findings, [&](const LintFinding& f) {
return f.file == file && ctx.Suppressed(f.rule, f.line);
});
if (ctx.content != original) {
summary.changedFiles.push_back(file);
if (opts.mode == LintMode::Apply) {
std::ofstream out(file, std::ios::binary | std::ios::trunc);
out.write(ctx.content.data(), static_cast<std::streamsize>(ctx.content.size()));
out.close();
if (!out) {
std::println(std::cerr, "failed to write {}", shown(file).string());
++summary.errors;
}
}
}
}
std::sort(summary.findings.begin(), summary.findings.end(),
[](const LintFinding& a, const LintFinding& b) {
return std::tie(a.file, a.line) < std::tie(b.file, b.line);
});
Progress::Clear();
switch (opts.mode) {
case LintMode::Report: {
std::unordered_set<std::string> filesWithFindings;
for (const LintFinding& f : summary.findings) {
filesWithFindings.insert(f.file.string());
std::println("{}:{}: warning: {} [{}]", shown(f.file).string(), f.line, f.message, f.rule);
}
if (summary.findings.empty()) {
std::println("Lint clean: {} files, {} rules", summary.filesLinted, summary.rulesRun);
} else {
std::println("{} finding(s) in {} of {} files ({} rules)", summary.findings.size(), filesWithFindings.size(), summary.filesLinted, summary.rulesRun);
}
break;
}
case LintMode::Check: {
// gofmt -l style: the paths alone, then a one-line verdict.
// Report-only findings are lint's business, not printed here.
for (const fs::path& f : summary.changedFiles) {
std::println("{}", shown(f).string());
}
if (summary.changedFiles.empty()) {
std::println("Format check clean: {} files, {} rules", summary.filesLinted, summary.rulesRun);
} else {
std::println("{} file(s) would be reformatted", summary.changedFiles.size());
}
break;
}
case LintMode::Apply: {
for (const fs::path& f : summary.changedFiles) {
std::println("formatted: {}", shown(f).string());
}
if (summary.changedFiles.empty()) {
std::println("Nothing to format: {} files, {} rules", summary.filesLinted, summary.rulesRun);
} else {
std::println("Formatted {} of {} files ({} rules)", summary.changedFiles.size(), summary.filesLinted, summary.rulesRun);
}
break;
}
}
return summary;
}