Crafter.Build/tests/HouseRules/main.cpp
Jorijn van der Graaf 91e4f59a94 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

335 lines
18 KiB
C++

// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
import Crafter.Build;
#include "../../lint-rules.h"
namespace fs = std::filesystem;
using namespace Crafter;
// Validates the house-style ruleset in lint-rules.h — the transforms and
// reports this repo (and sibling repos that copy the header) rely on. Each
// case writes a scratch file, runs ONE rule via the glob filter, and asserts
// the findings and/or rewritten bytes.
namespace {
std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
struct RuleRun {
LintSummary summary;
std::string text; // file content after the run
};
RuleRun RunRule(std::string_view content, std::string_view rule, LintMode mode) {
static std::int32_t Counter = 0;
fs::path dir = fs::temp_directory_path() / "crafter-build-house-rules" / std::format("case-{}", Counter++);
fs::remove_all(dir);
fs::create_directories(dir);
{
std::ofstream f(dir / "f.cpp", std::ios::binary | std::ios::trunc);
f.write(content.data(), static_cast<std::streamsize>(content.size()));
}
Configuration cfg;
cfg.path = dir;
cfg.name = "house-fixture";
cfg.outputName = "house-fixture";
cfg.target = HostTarget();
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "f" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
ProjectLint::AddProjectLintRules(cfg);
RunLintOptions opts;
opts.mode = mode;
opts.globs = { std::string(rule) };
RuleRun run;
run.summary = RunLint(cfg, opts);
std::ifstream f(dir / "f.cpp", std::ios::binary);
std::stringstream buffer;
buffer << f.rdbuf();
run.text = std::move(buffer).str();
return run;
}
bool HasFinding(const LintSummary& s, std::string_view fragment) {
return std::any_of(s.findings.begin(), s.findings.end(), [&](const LintFinding& f) { return f.message.contains(fragment); });
}
}
int main() {
// fixed-width-types: int/long long convert; main/argc and extern "C" stay.
{
RuleRun r = RunRule("int Foo(long long v) { int x = 5; return x; }\n" "extern \"C\" int setenv(const char* n, const char* v, int o);\n" "int main(int argc, char** argv) { return 0; }\n", "fixed-width-types", LintMode::Apply);
Check(r.text.contains("std::int32_t Foo(std::int64_t v) { std::int32_t x = 5;"), "fixed-width converts int and long long");
Check(r.text.contains("extern \"C\" int setenv"), "extern \"C\" prototype keeps int");
Check(r.text.contains("int main(int argc"), "main/argc keep int");
}
// fixed-width-types is signed/unsigned aware, in any specifier order;
// bare char (text) and long double (floating) are not integers.
{
RuleRun r = RunRule("void F() {\n"
" unsigned a = 1;\n"
" unsigned int b = 2;\n"
" unsigned long long c = 3;\n"
" long unsigned int d = 4;\n"
" unsigned short e = 5;\n"
" signed f = 6;\n"
" signed char g = 7;\n"
" auto h = static_cast<unsigned char>(g);\n"
" char text = 'x';\n"
" long double pi = 3.14L;\n"
"}\n",
"fixed-width-types", LintMode::Apply);
Check(r.text.contains("std::uint32_t a = 1;"), "bare unsigned -> uint32");
Check(r.text.contains("std::uint32_t b = 2;"), "unsigned int -> uint32");
Check(r.text.contains("std::uint64_t c = 3;"), "unsigned long long -> uint64");
Check(r.text.contains("std::uint64_t d = 4;"), "long unsigned int (reordered) -> uint64");
Check(r.text.contains("std::uint16_t e = 5;"), "unsigned short -> uint16");
Check(r.text.contains("std::int32_t f = 6;"), "bare signed -> int32");
Check(r.text.contains("std::int8_t g = 7;"), "signed char -> int8");
Check(r.text.contains("static_cast<std::uint8_t>(g)"), "unsigned char -> uint8");
Check(r.text.contains("char text = 'x';"), "bare char stays (text, not an integer)");
Check(r.text.contains("long double pi = 3.14L;"), "long double stays (floating type)");
}
// brace-style: Allman brace joins; standalone scope block stays.
{
RuleRun r = RunRule("void Foo()\n{\n}\n\nvoid Bar() {\n Baz();\n {\n Qux();\n }\n}\n", "brace-style", LintMode::Apply);
Check(r.text.contains("void Foo() {"), "Allman brace joined onto header");
Check(r.text.contains("Baz();\n {"), "scope block brace untouched");
}
// if-single-line: short body joins; braced body stays.
{
RuleRun r = RunRule("void F(bool b) {\n if (b)\n Run();\n if (b) {\n Run();\n }\n}\n", "if-single-line", LintMode::Apply);
Check(r.text.contains("if (b) Run();"), "single-statement if body joined");
Check(r.text.contains("if (b) {"), "braced if body untouched");
}
// single-declaration: simple multi-decl splits; pointer decl only reports.
{
RuleRun r = RunRule("void F() {\n bool a = false, b = true;\n}\n", "single-declaration", LintMode::Apply);
Check(r.text.contains("bool a = false;\n bool b = true;"), "multi-declaration split");
}
// wrap-join: short wrapped call joins; operator chain joins; long stays.
{
RuleRun r = RunRule("void F() {\n G(alpha,\n beta);\n bool x = alpha\n && beta;\n}\n", "wrap-join", LintMode::Apply);
Check(r.text.contains("G(alpha, beta);"), "wrapped call arguments joined");
Check(r.text.contains("bool x = alpha && beta;"), "operator continuation joined");
}
// format-concat: literal-adjacent + rewrites; += RHS rewrites; args survive.
{
RuleRun r = RunRule("void F(std::string name, std::string cmd) {\n"
" std::string a = name + \".cpp\";\n"
" std::string b = \"pre-\" + name + \"-post\";\n"
" cmd += \" \" + name;\n"
" std::string keep = name + cmd;\n"
" fs::path p;\n"
" std::string c = p.stem().string() + \".pcm\";\n"
" fs::path d = std::string(cmd) + \".so\";\n"
"}\n",
"format-concat", LintMode::Apply);
Check(r.text.contains("std::string a = std::format(\"{}.cpp\", name);"), "trailing literal converts");
Check(!r.text.contains("namestd::format"), "replacement splices at the operand boundary");
Check(r.text.contains("std::string b = std::format(\"pre-{}-post\", name);"), "sandwich chain converts");
Check(r.text.contains("cmd += std::format(\" {}\", name);"), "+= RHS converts");
Check(r.text.contains("std::string keep = name + cmd;"), "literal-free + is left alone");
Check(r.text.contains("std::string c = std::format(\"{}.pcm\", p.stem().string());"), "call-chain left operand consumed whole (the stringstd regression)");
Check(r.text.contains("fs::path d = std::format(\"{}.so\", std::string(cmd));"), "constructor-call left operand consumed whole");
}
{
// Ternary and raw-string lines are reported, never rewritten.
RuleRun r = RunRule("std::string F(bool b, std::string n) { return b ? n + \".x\" : n; }\n", "format-concat", LintMode::Report);
Check(HasFinding(r.summary, "not auto-fixable"), "ternary concat reports instead of fixing");
Check(r.text.contains("n + \".x\""), "ternary concat untouched on disk");
}
// paren-spacing: single-space padding removed; wrapped call ends keep.
{
RuleRun r = RunRule("void F() {\n G( x );\n}\n", "paren-spacing", LintMode::Apply);
Check(r.text.contains("G(x);"), "paren padding removed");
}
// naming: wrong-case function/type/static/global flagged; camel local passes.
{
RuleRun r = RunRule("namespace {\n"
" std::int32_t bad_global = 0;\n"
" struct lint_thing {};\n"
"}\n"
"void lower_func() {\n"
" static bool lowerStatic = false;\n"
" std::int32_t fineLocal = 1;\n"
"}\n",
"naming", LintMode::Report);
Check(HasFinding(r.summary, "'bad_global' should be PascalCase"), "lowercase global flagged");
Check(HasFinding(r.summary, "'lint_thing' should be PascalCase"), "lowercase type flagged");
Check(HasFinding(r.summary, "'lower_func' should be PascalCase"), "lowercase function flagged");
Check(HasFinding(r.summary, "'lowerStatic' should be PascalCase"), "camel static flagged");
Check(!HasFinding(r.summary, "fineLocal"), "camelCase local passes");
}
{
// Statement calls with inline lambda arguments are NOT function
// definitions (the any_of regression); a genuine lowercase one-liner
// method still is.
RuleRun r = RunRule("struct Holder {\n"
" bool clean() const { return true; }\n"
"};\n"
"void T(std::vector<std::int32_t>& v) {\n"
" bool x = std::any_of(v.begin(), v.end(), [](std::int32_t n) { return n > 0; });\n"
" std::erase_if(v, [](std::int32_t n) { return n < 0; });\n"
"}\n",
"naming", LintMode::Report);
Check(!HasFinding(r.summary, "any_of"), "call with lambda argument is not a function definition");
Check(!HasFinding(r.summary, "erase_if"), "bare statement call is not a function definition");
Check(HasFinding(r.summary, "'clean' should be PascalCase"), "lowercase one-liner method still flagged");
}
// enum-class + no-iostream-print reports.
{
RuleRun r = RunRule("enum Color { Red };\n", "enum-class", LintMode::Report);
Check(HasFinding(r.summary, "enum class"), "plain enum flagged");
}
{
RuleRun r = RunRule("void F() { std::cout << 1; }\n", "no-iostream-print", LintMode::Report);
Check(HasFinding(r.summary, "std::println"), "std::cout flagged");
}
// The whole ruleset is idempotent: a second Apply changes nothing.
{
std::string_view source = "int Foo()\n{\n std::string s = std::string(\"a\") + \"b\";\n if (true)\n return 1;\n return 0;\n}\n";
static constexpr std::string_view AllRules = "*";
RuleRun first = RunRule(source, AllRules, LintMode::Apply);
RuleRun second = RunRule(first.text, AllRules, LintMode::Apply);
Check(second.summary.changedFiles.empty(), "second apply of the full ruleset is a no-op");
Check(first.text != source, "first apply actually changed the fixture");
}
// Suppression directives against house transforms: the driver reverts
// line-preserving edits (fixed-width) and the count-changing rules check
// Suppressed() themselves (wrap-join).
{
RuleRun r = RunRule("void F() {\n"
" // lint-disable-next-line fixed-width-types\n"
" int keep = 1;\n"
" int convert = 2;\n"
"}\n",
"fixed-width-types", LintMode::Apply);
Check(r.text.contains("int keep = 1;"), "next-line directive keeps the suppressed int");
Check(r.text.contains("std::int32_t convert = 2;"), "unsuppressed line still converts");
}
{
RuleRun r = RunRule("void F() {\n"
" // lint-disable-next-line wrap-join\n"
" G(alpha,\n"
" beta);\n"
" H(alpha,\n"
" beta);\n"
"}\n",
"wrap-join", LintMode::Apply);
Check(r.text.contains("G(alpha,\n"), "suppressed wrap stays wrapped");
Check(r.text.contains("H(alpha, beta);"), "unsuppressed wrap still joins");
}
// wrap-join converges in ONE run: joining the inner paren wrap balances
// the && line, which only then becomes an operator-joinable continuation.
{
RuleRun r = RunRule("void F() {\n"
" bool ok = alpha\n"
" && beta(gamma,\n"
" delta);\n"
"}\n",
"wrap-join", LintMode::Apply);
Check(r.text.contains("bool ok = alpha && beta(gamma, delta);"), "self-enabling joins reach the fixpoint in one apply");
RuleRun second = RunRule(r.text, "wrap-join", LintMode::Apply);
Check(second.summary.changedFiles.empty(), "wrap-join is idempotent after the fixpoint");
}
// The guards used to be substring probes over the raw line, so a literal
// whose TEXT contained `//` or `R"` looked like a comment or a raw string
// and silently disabled the rule. Both shapes occur in this repo's own
// sources — "MARKER" ends in the characters R", and any string mentioning
// a lint-disable directive contains //.
{
RuleRun r = RunRule("void F() {\n" " auto hit = text.find(\"MARKER\",\n" " start);\n" "}\n", "wrap-join", LintMode::Apply);
Check(r.text.contains("text.find(\"MARKER\", start);"), "R\" inside a literal no longer blocks wrap-join");
}
{
RuleRun r = RunRule("void F() {\n" " Check(read() == \"// lint-disable-next-line trim\\n\",\n" " \"message\");\n" "}\n", "wrap-join", LintMode::Apply);
Check(r.text.contains("\\n\", \"message\");"), "// inside a literal no longer blocks wrap-join");
}
// A real trailing comment still blocks the join: text pulled up past a
// `//` would be swallowed by it.
{
RuleRun r = RunRule("void F() {\n" " auto v = g(alpha, // why\n" " beta);\n" "}\n", "wrap-join", LintMode::Apply);
Check(r.text.contains("g(alpha, // why\n"), "a real comment still blocks wrap-join");
}
// A genuinely multi-line literal is never reflowed — joining its lines
// would change the string's contents.
{
std::string_view source = "void F() {\n"
" auto text = R\"sql(SELECT a,\n"
" b FROM t)sql\";\n"
"}\n";
RuleRun r = RunRule(source, "wrap-join", LintMode::Apply);
Check(r.text == source, "wrap-join leaves a multi-line raw string alone");
RuleRun paren = RunRule(source, "paren-spacing", LintMode::Apply);
Check(paren.text == source, "paren-spacing leaves a multi-line raw string alone");
}
// Type keywords inside a literal are text, not declarations.
{
std::string_view source = "void F() {\n"
" auto sql = R\"q(int x; unsigned long y;)q\";\n"
"}\n";
RuleRun r = RunRule(source, "fixed-width-types", LintMode::Apply);
Check(r.text == source, "fixed-width-types leaves type names inside a raw string alone");
}
// no-char-pointer reads the AST, so it distinguishes OUR char* from one
// whose spelling belongs to somebody else's header. This is what replaced
// the substring denylist (argv, getenv, c_str, reinterpret_cast, …): each
// entry there disabled the rule for a whole line, and the list could only
// grow as new libraries arrived.
{
RuleRun r = RunRule("#include <cstdlib>\n"
"#include <string>\n"
"extern \"C\" const char* CApiEntry(const char* path);\n"
"char* OurBadApi(char* input) { return input; }\n"
"void F() {\n"
" char* fromLibc = std::getenv(\"HOME\");\n"
" std::string mine = \"ok\";\n"
" const char* toLibc = mine.c_str();\n"
" auto raw = reinterpret_cast<char*>(&mine);\n"
"}\n",
"no-char-pointer", LintMode::Report);
// Ours, so reported: the declaration and its parameter.
Check(HasFinding(r.summary, "'OurBadApi'"), "no-char-pointer: our own char* return is reported");
Check(HasFinding(r.summary, "'input'"), "no-char-pointer: our own char* parameter is reported");
// Foreign, so exempt — each for a reason, not by name.
Check(!HasFinding(r.summary, "'CApiEntry'"), "no-char-pointer: extern \"C\" declaration is exempt");
Check(!HasFinding(r.summary, "'path'"), "no-char-pointer: extern \"C\" parameter is exempt");
Check(!HasFinding(r.summary, "'fromLibc'"), "no-char-pointer: a value from libc is exempt");
Check(!HasFinding(r.summary, "'toLibc'"), "no-char-pointer: a value from c_str() is exempt");
// A local whose deduced type is char* is still our declaration, so it
// is reported. The old denylist exempted every line mentioning
// reinterpret_cast; a deliberate low-level cast now takes an explicit
// lint-disable comment, which is at least visible at the site.
Check(HasFinding(r.summary, "'raw'"), "no-char-pointer: a deduced char* local is still ours");
}
if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", Failures);
return 1;
}
return 0;
}