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>
This commit is contained in:
parent
29888fc5ba
commit
91e4f59a94
7 changed files with 209 additions and 40 deletions
|
|
@ -74,7 +74,6 @@ namespace {
|
|||
decltype(&clang_getCursorLocation) GetCursorLocation = nullptr;
|
||||
decltype(&clang_getCursorExtent) GetCursorExtent = nullptr;
|
||||
decltype(&clang_getCursorReferenced) GetCursorReferenced = nullptr;
|
||||
decltype(&clang_getCursorLanguage) GetCursorLanguage = nullptr;
|
||||
decltype(&clang_Cursor_getStorageClass) GetStorageClass = nullptr;
|
||||
decltype(&clang_EnumDecl_isScoped) EnumDeclIsScoped = nullptr;
|
||||
decltype(&clang_isCursorDefinition) IsCursorDefinition = nullptr;
|
||||
|
|
@ -88,6 +87,7 @@ namespace {
|
|||
decltype(&clang_disposeString) DisposeString = nullptr;
|
||||
decltype(&clang_getFileName) GetFileName = nullptr;
|
||||
decltype(&clang_Cursor_isNull) CursorIsNull = nullptr;
|
||||
decltype(&clang_getResultType) GetResultType = nullptr;
|
||||
};
|
||||
|
||||
LibClang LoadLibClang() {
|
||||
|
|
@ -152,7 +152,6 @@ namespace {
|
|||
bind(lib.GetCursorLocation, "clang_getCursorLocation");
|
||||
bind(lib.GetCursorExtent, "clang_getCursorExtent");
|
||||
bind(lib.GetCursorReferenced, "clang_getCursorReferenced");
|
||||
bind(lib.GetCursorLanguage, "clang_getCursorLanguage");
|
||||
bind(lib.GetStorageClass, "clang_Cursor_getStorageClass");
|
||||
bind(lib.EnumDeclIsScoped, "clang_EnumDecl_isScoped");
|
||||
bind(lib.IsCursorDefinition, "clang_isCursorDefinition");
|
||||
|
|
@ -166,6 +165,7 @@ namespace {
|
|||
bind(lib.DisposeString, "clang_disposeString");
|
||||
bind(lib.GetFileName, "clang_getFileName");
|
||||
bind(lib.CursorIsNull, "clang_Cursor_isNull");
|
||||
bind(lib.GetResultType, "clang_getResultType");
|
||||
if (!missing.empty()) {
|
||||
lib.handle = nullptr;
|
||||
lib.error = std::format("loaded {} but it is missing {}", candidates.front(), join(missing));
|
||||
|
|
@ -265,10 +265,10 @@ namespace {
|
|||
// ---------------- AST ----------------
|
||||
|
||||
std::string TakeString(const LibClang& lc, CXString s) {
|
||||
// clang_getCString returns const char*; the spelling is libclang's, not
|
||||
// ours. Suppressed by hand until no-char-pointer reads the AST and can
|
||||
// see that for itself.
|
||||
// lint-disable-next-line no-char-pointer
|
||||
// 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.
|
||||
const char* raw = lc.GetCString(s);
|
||||
std::string out = raw ? raw : "";
|
||||
lc.DisposeString(s);
|
||||
|
|
@ -363,8 +363,20 @@ namespace {
|
|||
const fs::path* projectRoot = nullptr;
|
||||
std::vector<LintDecl>* out = nullptr;
|
||||
std::vector<std::size_t> stack; // indices of the enclosing declarations
|
||||
std::int32_t externCDepth = 0; // inside how many extern "C" blocks
|
||||
};
|
||||
|
||||
// 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";
|
||||
}
|
||||
|
||||
bool PathInsideRoot(const fs::path& p, const fs::path& root);
|
||||
|
||||
// True when `cursor` names something declared outside the project — a
|
||||
|
|
@ -398,18 +410,41 @@ namespace {
|
|||
// std alone. Prune before doing any work.
|
||||
if (!lc.LocationIsFromMainFile(location)) return CXChildVisit_Continue;
|
||||
|
||||
LintDeclKind mapped = MapCursorKind(lc.GetCursorKind(cursor));
|
||||
CXCursorKind kind = lc.GetCursorKind(cursor);
|
||||
LintDeclKind mapped = MapCursorKind(kind);
|
||||
if (mapped == LintDeclKind::Other) {
|
||||
// Not a declaration we model, but a reference to a foreign entity
|
||||
// inside one — an argument to a libc call, a member of a libc++
|
||||
// type — is exactly what marks the enclosing declaration as sitting
|
||||
// on an interop boundary.
|
||||
if (!walk.stack.empty() && ResolvesOutsideProject(lc, cursor, *walk.projectRoot)) {
|
||||
(*walk.out)[walk.stack.back()].isForeignApi = true;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// Recursed by hand rather than with CXChildVisit_Recurse so the
|
||||
// enclosing-declaration stack stays accurate: the callback is never
|
||||
// told when a subtree ends.
|
||||
if (kind == CXCursor_LinkageSpec) {
|
||||
CXSourceRange extent = lc.GetCursorExtent(cursor);
|
||||
std::uint32_t specBegin = 0;
|
||||
std::uint32_t specEnd = 0;
|
||||
lc.GetFileLocation(lc.GetRangeStart(extent), nullptr, nullptr, nullptr, &specBegin);
|
||||
lc.GetFileLocation(lc.GetRangeEnd(extent), nullptr, nullptr, nullptr, &specEnd);
|
||||
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;
|
||||
lc.VisitChildren(cursor, &VisitDecl, &walk);
|
||||
if (isC) --walk.externCDepth;
|
||||
return CXChildVisit_Continue;
|
||||
}
|
||||
lc.VisitChildren(cursor, &VisitDecl, &walk);
|
||||
return CXChildVisit_Continue;
|
||||
}
|
||||
|
|
@ -417,7 +452,12 @@ namespace {
|
|||
LintDecl decl;
|
||||
decl.kind = mapped;
|
||||
decl.name = TakeString(lc, lc.GetCursorSpelling(cursor));
|
||||
decl.type = TakeString(lc, lc.GetTypeSpelling(lc.GetCursorType(cursor)));
|
||||
// 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;
|
||||
decl.type = TakeString(lc, lc.GetTypeSpelling(callable ? lc.GetResultType(lc.GetCursorType(cursor)) : lc.GetCursorType(cursor)));
|
||||
CXFile nameFile = nullptr;
|
||||
std::uint32_t nameLine = 0;
|
||||
std::uint32_t nameColumn = 0;
|
||||
|
|
@ -435,9 +475,11 @@ namespace {
|
|||
decl.isDefinition = lc.IsCursorDefinition(cursor) != 0;
|
||||
decl.isStatic = lc.GetStorageClass(cursor) == CX_SC_Static;
|
||||
decl.isScopedEnum = mapped == LintDeclKind::Enum && lc.EnumDeclIsScoped(cursor) != 0;
|
||||
// C language linkage: set for anything inside an extern "C" block, which
|
||||
// is where a C API's spelling is not ours to modernise.
|
||||
decl.isExternC = lc.GetCursorLanguage(cursor) == CXLanguage_C;
|
||||
// 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;
|
||||
// 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
|
||||
|
|
@ -924,7 +966,12 @@ format` applies it to disk, and `crafter-build lint` reports where it would.
|
|||
std::string command;
|
||||
try {
|
||||
CompileCommand assembled = GetCompileCommand(cfg);
|
||||
command = assembled.command;
|
||||
// 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;
|
||||
if (!fs::exists(assembled.stdPcmDir/"std.pcm")) {
|
||||
Progress::Task task(std::format("Building std PCM ({}-{})", cfg.target, cfg.march));
|
||||
fs::create_directories(assembled.stdPcmDir);
|
||||
|
|
@ -963,6 +1010,15 @@ format` applies it to disk, and `crafter-build lint` reports where it would.
|
|||
// decides what is safe to touch. Skip it and make the run fail.
|
||||
if (rule->needsAst) {
|
||||
if (opts.noAst) continue;
|
||||
// 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;
|
||||
if (!ctx.AstAvailable()) {
|
||||
ctx.Report(0, std::format("rule '{}' needs an AST, which is unavailable: {}", rule->name, ctx.AstUnavailableReason()));
|
||||
++summary.errors;
|
||||
|
|
|
|||
Loading…
Reference in a new issue