Merge branch 'claude/project-dependencies': project.cpp dependencies, AGENTS.md
Brings in `#pragma crafter dependency`, the switch to Crafter.LintRules for the house style, and the contributor guide in AGENTS.md. The branch predates the musl host work on master, and both rewrote the command that compiles project.so, so the merge reconciles them by hand: - LoadProject compiles project.so for hostConfig.target (HostTarget(), from master) with the pre-dependency module path and archives from the branch. -fuse-ld=lld appears once, with both reasons in the comment. - ForceHostTooling added -fPIC only for the literal x86_64-pc-linux-gnu triple. hostConfig now comes from HostTarget(), so on Alpine the host is x86_64-alpine-linux-musl and the dependency archive would not have been position-independent. It now matches any *-linux-gnu or *-linux-musl host, as project.cpp and the cmake flags do since the musl change, and its comment no longer argues against HostTarget(). - The ProjectDependency test compared the dependency's target against the glibc triple, which the musl CI job would fail; it compares against HostTarget(). - README keeps master's paragraph on the bootstrap triple. AGENTS.md describes the test block as `if (linuxTarget)`, which is what project.cpp has now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
c3d97be59e
18 changed files with 784 additions and 1635 deletions
|
|
@ -1,513 +0,0 @@
|
|||
// 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 splits on the AST, so each declarator carries its own
|
||||
// resolved type. That is the whole reason it is not a token rule: copying
|
||||
// the head's type prefix turns `int* a, b;` into `int* a; int* b;` and
|
||||
// silently changes b from int to int*.
|
||||
{
|
||||
RuleRun r = RunRule("#include <vector>\n"
|
||||
"void F() {\n"
|
||||
" bool a = false, b = true;\n"
|
||||
" int* ptrA = nullptr, *ptrB = nullptr;\n"
|
||||
" int* mixed = nullptr, plain = 7;\n"
|
||||
" std::vector<int> tmplA{}, tmplB{};\n"
|
||||
" int callA = g(), callB = h();\n"
|
||||
" int keep = 1, kept = 2; // trailing comment\n"
|
||||
"}\n",
|
||||
"single-declaration", LintMode::Apply);
|
||||
Check(r.text.contains("bool a = false;\n bool b = true;"), "single-declaration: simple split");
|
||||
Check(r.text.contains("int* ptrA = nullptr;\n int* ptrB = nullptr;"), "single-declaration: pointers split, star kept on the type");
|
||||
// The case a token-based splitter gets wrong.
|
||||
Check(r.text.contains("int* mixed = nullptr;\n int plain = 7;"), "single-declaration: only the starred declarator is a pointer");
|
||||
Check(r.text.contains("std::vector<int> tmplA{};\n std::vector<int> tmplB{};"), "single-declaration: template arguments are not a bail-out");
|
||||
Check(r.text.contains("int callA = g();\n int callB = h();"), "single-declaration: call initialisers are not a bail-out");
|
||||
// A comment after the ';' is outside the replaced range, so it survives;
|
||||
// the line-based version refused the whole line instead.
|
||||
Check(r.text.contains("int keep = 1;\n int kept = 2; // trailing comment"), "single-declaration: trailing comment survives the split");
|
||||
RuleRun again = RunRule(r.text, "single-declaration", LintMode::Apply);
|
||||
Check(again.summary.changedFiles.empty(), "single-declaration: idempotent");
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
// Cases the line-based heuristic structurally could not see. It only looked
|
||||
// at declarations starting at cumulative paren depth 0 and inferred scope
|
||||
// from a running {/} count, so a wrapped signature, a specifier split over
|
||||
// two lines, or a local shadowing a member all escaped it.
|
||||
{
|
||||
RuleRun r = RunRule("#include <string>\n"
|
||||
"namespace Outer {\n"
|
||||
" int wrapped_function(\n"
|
||||
" int first,\n"
|
||||
" int second) { return first + second; }\n"
|
||||
" static\n"
|
||||
" int splitStatic = 1;\n"
|
||||
" struct Holder {\n"
|
||||
" int Member = 0;\n"
|
||||
" void Method() {\n"
|
||||
" int Local = 1;\n"
|
||||
" (void)Local;\n"
|
||||
" }\n"
|
||||
" };\n"
|
||||
"}\n"
|
||||
"extern \"C\" int c_api_entry(const char* name);\n",
|
||||
"naming", LintMode::Report);
|
||||
// A signature wrapped over lines is still a function declaration.
|
||||
Check(HasFinding(r.summary, "'wrapped_function' should be PascalCase"), "naming: wrapped signature is seen");
|
||||
// `static` on its own line still applies to the declaration below it.
|
||||
Check(HasFinding(r.summary, "'splitStatic' should be PascalCase"), "naming: split specifier is seen");
|
||||
// Members are camelCase; the enclosing kind decides, not a brace count.
|
||||
Check(HasFinding(r.summary, "'Member' should be camelCase"), "naming: member is checked as a member");
|
||||
// A local inside a method is a local, not a member and not a global.
|
||||
Check(HasFinding(r.summary, "'Local' should be camelCase"), "naming: local inside a method is a local");
|
||||
// Named by libc, not by us.
|
||||
Check(!HasFinding(r.summary, "c_api_entry"), "naming: extern \"C\" declaration is exempt");
|
||||
Check(!HasFinding(r.summary, "'Method'"), "naming: PascalCase method passes");
|
||||
Check(!HasFinding(r.summary, "'Holder'"), "naming: PascalCase type passes");
|
||||
}
|
||||
|
||||
// fixed-width-types must not rewrite an integer whose width somebody else's
|
||||
// header chose — the rewrite would leave code that no longer matches the API
|
||||
// it calls. Derived from the AST, so the exemption is per-declaration rather
|
||||
// than the old per-line textual one, which both missed cases and disabled
|
||||
// the rule for anything else sharing a line with `argc`/`extern "`.
|
||||
{
|
||||
RuleRun r = RunRule("#include <cstdlib>\n"
|
||||
"extern \"C\" unsigned long CApiCall(unsigned int flags);\n"
|
||||
"long OurOwnApi(int value) { return value; }\n"
|
||||
"void F() {\n"
|
||||
" unsigned long fromLibc = std::strtoul(\"1\", nullptr, 10);\n"
|
||||
" unsigned ours = 1;\n"
|
||||
" (void)fromLibc; (void)ours;\n"
|
||||
"}\n"
|
||||
"int main(int argc, char** argv) {\n"
|
||||
" for (int i = 0; i < argc; ++i) { (void)argv[i]; }\n"
|
||||
" return 0;\n"
|
||||
"}\n",
|
||||
"fixed-width-types", LintMode::Apply);
|
||||
// Somebody else's widths, kept.
|
||||
Check(r.text.contains("extern \"C\" unsigned long CApiCall(unsigned int flags);"), "fixed-width: extern \"C\" signature keeps its widths");
|
||||
Check(r.text.contains("unsigned long fromLibc = std::strtoul"), "fixed-width: a value from a C library keeps its width");
|
||||
Check(r.text.contains("int main(int argc, char** argv)"), "fixed-width: main's signature is untouched");
|
||||
// Ours, converted.
|
||||
Check(r.text.contains("std::int64_t OurOwnApi(std::int32_t value)"), "fixed-width: our own signature converts");
|
||||
Check(r.text.contains("std::uint32_t ours = 1;"), "fixed-width: our own local converts");
|
||||
// The loop counter inside main's BODY is ordinary code. The old rule
|
||||
// skipped it because `argc` appeared on the same line; only the
|
||||
// signature is exempt now, not everything near it.
|
||||
Check(r.text.contains("for (std::int32_t i = 0;"), "fixed-width: main's body is not exempt, only its signature");
|
||||
}
|
||||
|
||||
// enum-class asks for the next TOKEN after `enum`, so a declaration split
|
||||
// over lines reads the same as one that is not. The regex it replaced
|
||||
// required the name to follow `enum` on the same line.
|
||||
{
|
||||
RuleRun r = RunRule("enum Plain { A };\n"
|
||||
"enum\n"
|
||||
" Split { B };\n"
|
||||
"enum class Scoped { C };\n"
|
||||
"enum\n"
|
||||
" class SplitScoped { D };\n"
|
||||
"enum struct AlsoFine { E };\n",
|
||||
"enum-class", LintMode::Report);
|
||||
Check(r.summary.findings.size() == 2, std::format("enum-class: exactly the two plain enums ({} found)", r.summary.findings.size()));
|
||||
const bool onFirst = std::any_of(r.summary.findings.begin(), r.summary.findings.end(), [](const LintFinding& f) { return f.line == 1; });
|
||||
const bool onSplit = std::any_of(r.summary.findings.begin(), r.summary.findings.end(), [](const LintFinding& f) { return f.line == 2; });
|
||||
Check(onFirst, "enum-class: single-line plain enum reported");
|
||||
Check(onSplit, "enum-class: line-split plain enum reported at the keyword");
|
||||
}
|
||||
|
||||
// const-local's mutation analysis is exact for scalars: assignment, ++/--,
|
||||
// address-of and binding to a non-const reference are the only ways to
|
||||
// write one, and all four are tracked. Both directions matter — a missed
|
||||
// write means advising const on something that cannot be const.
|
||||
{
|
||||
RuleRun r = RunRule("void Mutate(int& out);\n"
|
||||
"void ReadOnly(const int& in);\n"
|
||||
"void ByValue(int v);\n"
|
||||
"int Compute();\n"
|
||||
"void F() {\n"
|
||||
" int neverWritten = 1;\n"
|
||||
" int assigned = 1; assigned = 2;\n"
|
||||
" int incremented = 1; ++incremented;\n"
|
||||
" int compound = 1; compound += 2;\n"
|
||||
" int addressed = 1; int* taken = &addressed;\n"
|
||||
" int toMutatingRef = 1; Mutate(toMutatingRef);\n"
|
||||
" int toConstRef = 1; ReadOnly(toConstRef);\n"
|
||||
" int toByValue = 1; ByValue(toByValue);\n"
|
||||
" int boundToRef = 1; int& alias = boundToRef;\n"
|
||||
" for (int loop = 0; loop < 1; ++loop) { (void)loop; }\n"
|
||||
" (void)taken; (void)alias;\n"
|
||||
"}\n",
|
||||
"const-local", LintMode::Report);
|
||||
Check(HasFinding(r.summary, "'neverWritten'"), "const-local: an unwritten local is reported");
|
||||
Check(HasFinding(r.summary, "'toConstRef'"), "const-local: passing to a const& is not a write");
|
||||
Check(HasFinding(r.summary, "'toByValue'"), "const-local: passing by value is not a write");
|
||||
Check(!HasFinding(r.summary, "'assigned'"), "const-local: assignment is a write");
|
||||
Check(!HasFinding(r.summary, "'incremented'"), "const-local: ++ is a write");
|
||||
Check(!HasFinding(r.summary, "'compound'"), "const-local: += is a write");
|
||||
Check(!HasFinding(r.summary, "'addressed'"), "const-local: taking an address counts as a write");
|
||||
Check(!HasFinding(r.summary, "'toMutatingRef'"), "const-local: binding to a non-const& parameter is a write");
|
||||
Check(!HasFinding(r.summary, "'boundToRef'"), "const-local: binding to a non-const& local is a write");
|
||||
Check(!HasFinding(r.summary, "'loop'"), "const-local: a mutated loop counter is not reported");
|
||||
// Pointers and range-for bindings are excluded: `T* const p` and
|
||||
// `for (T* const x : …)` are not spellings anybody writes.
|
||||
Check(!HasFinding(r.summary, "'taken'"), "const-local: pointer locals are out of scope");
|
||||
}
|
||||
{
|
||||
RuleRun r = RunRule("void F() {\n" " for (int each : Range()) { (void)each; }\n" "}\n", "const-local", LintMode::Report);
|
||||
Check(!HasFinding(r.summary, "'each'"), "const-local: a range-for binding is not reported");
|
||||
}
|
||||
|
||||
// constexpr-constant asks clang's constant evaluator, not the tokens of the
|
||||
// initialiser. sizeof and a fold over other constants are constant
|
||||
// expressions that no token scan can recognise; a call result is not.
|
||||
{
|
||||
RuleRun r = RunRule("int Compute();\n"
|
||||
"constexpr int Base = 4;\n"
|
||||
"struct Big { int a, b; };\n"
|
||||
"void F() {\n"
|
||||
" const int literal = 4;\n"
|
||||
" const int folded = 1 << 4;\n"
|
||||
" const int fromSizeof = sizeof(Big);\n"
|
||||
" const int fromOtherConstant = Base + 1;\n"
|
||||
" const int fromCall = Compute();\n"
|
||||
" constexpr int already = 8;\n"
|
||||
" (void)literal; (void)folded; (void)fromSizeof;\n"
|
||||
" (void)fromOtherConstant; (void)fromCall; (void)already;\n"
|
||||
"}\n",
|
||||
"constexpr-constant", LintMode::Report);
|
||||
Check(HasFinding(r.summary, "'literal'"), "constexpr: a literal constant is reported");
|
||||
Check(HasFinding(r.summary, "'folded'"), "constexpr: an operator fold over literals is reported");
|
||||
// Neither of these is reachable from a token scan of the initialiser.
|
||||
Check(HasFinding(r.summary, "'fromSizeof'"), "constexpr: sizeof folds");
|
||||
Check(HasFinding(r.summary, "'fromOtherConstant'"), "constexpr: a fold over another constant folds");
|
||||
Check(!HasFinding(r.summary, "'fromCall'"), "constexpr: a call result is not a constant expression");
|
||||
Check(!HasFinding(r.summary, "'already'"), "constexpr: an existing constexpr is not re-reported");
|
||||
}
|
||||
|
||||
if (Failures > 0) {
|
||||
std::println(std::cerr, "{} assertions failed", Failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
20
tests/ProjectDependency/fixture/app/project.cpp
Normal file
20
tests/ProjectDependency/fixture/app/project.cpp
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
import std;
|
||||
import Crafter.Build;
|
||||
#pragma crafter dependency "../greeter"
|
||||
import Greeter;
|
||||
using namespace Crafter;
|
||||
|
||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||
Configuration cfg;
|
||||
cfg.path = "./";
|
||||
cfg.name = Greet();
|
||||
cfg.outputName = "app";
|
||||
cfg.type = ConfigurationType::Executable;
|
||||
// Once by the module it exports, once by its configuration name; the
|
||||
// test expects the same object back for both.
|
||||
cfg.dependencies = { ProjectDependency("Greeter"), ProjectDependency("greeter") };
|
||||
return cfg;
|
||||
}
|
||||
8
tests/ProjectDependency/fixture/bad-both/project.cpp
Normal file
8
tests/ProjectDependency/fixture/bad-both/project.cpp
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import std;
|
||||
import Crafter.Build;
|
||||
#pragma crafter dependency "../greeter" branch "main" commit "0123abc"
|
||||
using namespace Crafter;
|
||||
|
||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||
return Configuration{};
|
||||
}
|
||||
8
tests/ProjectDependency/fixture/bad-nosource/project.cpp
Normal file
8
tests/ProjectDependency/fixture/bad-nosource/project.cpp
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import std;
|
||||
import Crafter.Build;
|
||||
#pragma crafter dependency
|
||||
using namespace Crafter;
|
||||
|
||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||
return Configuration{};
|
||||
}
|
||||
12
tests/ProjectDependency/fixture/cycle-a/project.cpp
Normal file
12
tests/ProjectDependency/fixture/cycle-a/project.cpp
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import std;
|
||||
import Crafter.Build;
|
||||
#pragma crafter dependency "../cycle-b"
|
||||
using namespace Crafter;
|
||||
|
||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||
Configuration cfg;
|
||||
cfg.path = "./";
|
||||
cfg.name = "cycle-a";
|
||||
cfg.outputName = "cycle-a";
|
||||
return cfg;
|
||||
}
|
||||
12
tests/ProjectDependency/fixture/cycle-b/project.cpp
Normal file
12
tests/ProjectDependency/fixture/cycle-b/project.cpp
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import std;
|
||||
import Crafter.Build;
|
||||
#pragma crafter dependency "../cycle-a"
|
||||
using namespace Crafter;
|
||||
|
||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||
Configuration cfg;
|
||||
cfg.path = "./";
|
||||
cfg.name = "cycle-b";
|
||||
cfg.outputName = "cycle-b";
|
||||
return cfg;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export module Greeter;
|
||||
import std;
|
||||
export std::string Greet() {
|
||||
return "hello again";
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export module Greeter;
|
||||
import std;
|
||||
// Not inline: the string has to come out of libgreeter.a, so a consumer that
|
||||
// only found the BMI and never linked the archive fails to load.
|
||||
export std::string Greet() {
|
||||
return "hello from greeter";
|
||||
}
|
||||
23
tests/ProjectDependency/fixture/greeter/project.cpp
Normal file
23
tests/ProjectDependency/fixture/greeter/project.cpp
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
import std;
|
||||
import Crafter.Build;
|
||||
namespace fs = std::filesystem;
|
||||
using namespace Crafter;
|
||||
|
||||
// The sibling project the consumer's project.cpp imports a module from.
|
||||
// ApplyStandardArgs on purpose: it reads CRAFTER_BUILD_MARCH from the
|
||||
// environment, and the pre-pass has to override whatever it picked.
|
||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
||||
Configuration cfg;
|
||||
cfg.path = "./";
|
||||
cfg.name = "greeter";
|
||||
cfg.outputName = "greeter";
|
||||
cfg.type = ConfigurationType::LibraryStatic;
|
||||
ApplyStandardArgs(cfg, args);
|
||||
std::array<fs::path, 1> ifaces = { "interfaces/Greeter" };
|
||||
std::array<fs::path, 0> impls = {};
|
||||
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||
return cfg;
|
||||
}
|
||||
151
tests/ProjectDependency/main.cpp
Normal file
151
tests/ProjectDependency/main.cpp
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
#include <stdlib.h>
|
||||
import std;
|
||||
import Crafter.Build;
|
||||
namespace fs = std::filesystem;
|
||||
using namespace Crafter;
|
||||
|
||||
// `#pragma crafter dependency`: a project.cpp that imports a module from a
|
||||
// sibling crafter-build project. LoadProject has to fetch and build that
|
||||
// project before project.cpp compiles, put its BMI and archive on the
|
||||
// project.so command line, and treat the archive as an input to the
|
||||
// staleness check. The fixture is copied to a scratch dir because one case
|
||||
// edits a source and every build drops bin/ and build/ trees next to it.
|
||||
//
|
||||
// A recompiled project.so can't be observed from the process that already
|
||||
// dlopened the old one — the loader hands back the mapping it has for that
|
||||
// path — so the "dependency changed" cases load through a child process:
|
||||
// this binary re-run with `--load <project.cpp>` prints the name the project
|
||||
// file produced.
|
||||
namespace {
|
||||
std::int32_t Failures = 0;
|
||||
|
||||
void Check(bool cond, std::string_view msg) {
|
||||
if (!cond) {
|
||||
std::println(std::cerr, "FAIL: {}", msg);
|
||||
++Failures;
|
||||
}
|
||||
}
|
||||
|
||||
// Expect LoadProject to throw; the message, or "" when it didn't.
|
||||
std::string LoadFails(const fs::path& projectFile) {
|
||||
std::array<std::string_view, 0> noArgs = {};
|
||||
try {
|
||||
(void)LoadProject(projectFile, noArgs);
|
||||
} catch (const std::exception& e) {
|
||||
return e.what();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// LoadProject in a fresh process; returns what it printed, trimmed.
|
||||
std::string LoadInChild(const fs::path& projectFile) {
|
||||
const fs::path self = fs::read_symlink("/proc/self/exe");
|
||||
const auto r = RunCommandWithTimeout(std::format("{} --load {}", self.string(), projectFile.string()), std::chrono::seconds(300));
|
||||
std::string out = r.output;
|
||||
while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back();
|
||||
if (r.exitCode != 0) return std::format("<exit {}> {}", r.exitCode, out);
|
||||
// The dependency build's progress lines share stdout; the name is the
|
||||
// last line.
|
||||
const std::size_t lastBreak = out.rfind('\n');
|
||||
return lastBreak == std::string::npos ? out : out.substr(lastBreak + 1);
|
||||
}
|
||||
|
||||
void ChangeGreeting(const fs::path& scratch) {
|
||||
const fs::path iface = scratch / "greeter" / "interfaces" / "Greeter.cppm";
|
||||
fs::copy_file(scratch / "greeter" / "interfaces" / "Greeter-changed.cppm.in", iface, fs::copy_options::overwrite_existing);
|
||||
// copy_file carries the source's mtime across, which would leave the
|
||||
// rewritten interface looking older than the BMI built from it.
|
||||
fs::last_write_time(iface, fs::file_time_type::clock::now());
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc == 3 && std::string_view(argv[1]) == "--load") {
|
||||
std::array<std::string_view, 0> noArgs = {};
|
||||
Configuration cfg = LoadProject(argv[2], noArgs);
|
||||
std::println("{}", cfg.name);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const fs::path installedHome = GetCrafterBuildHome();
|
||||
if (!fs::exists(installedHome / "Crafter.Build.cppm")) {
|
||||
std::println(std::cerr, "SKIP: no module sources at {}", installedHome.string());
|
||||
return 77;
|
||||
}
|
||||
|
||||
const fs::path fixture = fs::current_path() / "tests" / "ProjectDependency" / "fixture";
|
||||
const fs::path scratch = fs::temp_directory_path() / "crafter-build-project-dependency";
|
||||
std::error_code ec;
|
||||
fs::remove_all(scratch, ec);
|
||||
fs::create_directories(scratch);
|
||||
fs::copy(fixture, scratch, fs::copy_options::recursive);
|
||||
|
||||
// The dependency's project.cpp applies the standard args, which read the
|
||||
// march from the environment. project.so is compiled for the host, so
|
||||
// the pre-pass must override it or the dependency's BMIs won't load.
|
||||
setenv("CRAFTER_BUILD_MARCH", "x86-64-v2", 1);
|
||||
|
||||
const fs::path app = scratch / "app" / "project.cpp";
|
||||
const fs::path appSo = scratch / "app" / "build" / "project.so";
|
||||
std::array<std::string_view, 0> noArgs = {};
|
||||
|
||||
// The dependency is fetched, built and linked before project.cpp runs,
|
||||
// and ProjectDependency() hands it back by either name.
|
||||
try {
|
||||
Configuration cfg = LoadProject(app, noArgs);
|
||||
Check(cfg.name == "hello from greeter", std::format("project.cpp imported and called the dependency's module (got '{}')", cfg.name));
|
||||
Check(cfg.dependencies.size() == 2, "ProjectDependency resolved by module name and by configuration name");
|
||||
if (cfg.dependencies.size() == 2) {
|
||||
Check(cfg.dependencies[0] == cfg.dependencies[1], "both names resolve to the same dependency");
|
||||
const Configuration& dep = *cfg.dependencies[0];
|
||||
Check(dep.name == "greeter", "the dependency is the fixture's greeter project");
|
||||
Check(dep.target == HostTarget(), "the dependency was built for the host triple LoadProject uses");
|
||||
Check(dep.march == "native", "the environment's march was overridden for host tooling");
|
||||
Check(std::ranges::contains(dep.compileFlags, "-fPIC"), "the dependency archive was built position-independent");
|
||||
Check(fs::exists(ExpectedOutput(dep)), "the dependency archive exists");
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
std::println(std::cerr, "FAIL: LoadProject threw: {}", e.what());
|
||||
return 1;
|
||||
}
|
||||
Check(fs::exists(appSo), "project.so was produced");
|
||||
if (!fs::exists(appSo)) return 1;
|
||||
const fs::file_time_type firstSo = fs::last_write_time(appSo);
|
||||
|
||||
// Editing the dependency rebuilds it and recompiles project.so against
|
||||
// it — without touching project.cpp.
|
||||
ChangeGreeting(scratch);
|
||||
const std::string changed = LoadInChild(app);
|
||||
Check(changed == "hello again", std::format("a changed dependency reaches project.cpp (got '{}')", changed));
|
||||
Check(fs::last_write_time(appSo) > firstSo, "project.so was recompiled after the dependency changed");
|
||||
const fs::file_time_type secondSo = fs::last_write_time(appSo);
|
||||
|
||||
// And an unchanged dependency doesn't: "always stale" would be invisible
|
||||
// to every other case here.
|
||||
const std::string unchanged = LoadInChild(app);
|
||||
Check(unchanged == "hello again", std::format("an unchanged dependency still loads (got '{}')", unchanged));
|
||||
Check(fs::last_write_time(appSo) == secondSo, "project.so is left alone when nothing changed");
|
||||
|
||||
// Directive errors name the file and line.
|
||||
const std::string noSource = LoadFails(scratch / "bad-nosource" / "project.cpp");
|
||||
Check(noSource.contains("project.cpp:3:") && noSource.contains("expected a quoted source"), std::format("a directive without a source is rejected with its location (got '{}')", noSource));
|
||||
const std::string both = LoadFails(scratch / "bad-both" / "project.cpp");
|
||||
Check(both.contains("project.cpp:3:") && both.contains("mutually exclusive"), std::format("branch and commit together are rejected (got '{}')", both));
|
||||
|
||||
// Two project files that depend on each other stop instead of recursing.
|
||||
const std::string cycle = LoadFails(scratch / "cycle-a" / "project.cpp");
|
||||
Check(cycle.contains("circular project dependency"), std::format("a dependency cycle is reported (got '{}')", cycle));
|
||||
|
||||
// The accessor is only meaningful while a project file is running.
|
||||
try {
|
||||
(void)ProjectDependency("Greeter");
|
||||
Check(false, "ProjectDependency outside a project file throws");
|
||||
} catch (const std::exception& e) {
|
||||
Check(std::string_view(e.what()).contains("outside a project file"), std::format("ProjectDependency outside a project file explains itself (got '{}')", e.what()));
|
||||
}
|
||||
|
||||
return Failures > 0 ? 1 : 0;
|
||||
}
|
||||
Loading…
Reference in a new issue