fix: rebuild and relink on what changed, submodules at their revisions, and mingw found where it is
Some checks failed
Flatpak / flatpak (push) Failing after 1m12s

- Shaders record the files glslang opened (a depfile) and their settings
  (source path, stage, entry point, defines, include directories) in the
  build directory, and rebuild when an included file or a setting changes,
  not only the shader's own source.
- An executable relinks when its link command changed, not only when an
  object is newer: a link flag in project.cpp, or a dependency's library
  that moved (an ExternalDependency built into a new cache directory). The
  command is recorded beside the objects; its flags come from ordered sets,
  so the order the dependency threads finish in can't change it.
- An external's newest artifact counts its libDirs and versioned .so names.
- Submodules are checked out at the revisions the fetched commit records,
  from the URLs its .gitmodules gives, after every clone, switch, checkout
  and pull, so a pinned commit builds against its own submodules, and one
  moved by hand is put back; with no .gitmodules, git isn't asked.
- Each external clone's fetch and build hold a file lock: parallel builds
  pulled into one clone at once ("cannot lock ref").
- mingw:
  - -stdlib=libstdc++ always;
  - the std module's source is found through libstdc++.modules.json in the
    mingw toolchain's own library directories (the generic lib/ holds the
    host's Linux one), asking the compiler once a run;
  - a toolchain that isn't there is a build error saying where it looked,
    not std::terminate;
  - CRAFTER_MINGW_DIR names the prefix, the directory holding
    x86_64-w64-mingw32/ and lib/gcc/;
  - inside the Flatpak the host's /usr mingw is used.
- RunScript's exit-status check reads as the one range it is.
- Tests: ConcurrentExternalFetch, ExternalArtifactTime, GitSubmodules,
  LinkFlagsChange, MingwMissing, ShaderIncludeChange; ShaderCompile and
  ToolchainResolve extended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-20 19:08:13 +02:00
commit 653dd0bb9b
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
26 changed files with 1075 additions and 109 deletions

View file

@ -0,0 +1,95 @@
// 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;
// Builds that run at the same time share their dependencies' clones in the
// cache: the tests `crafter-build test --jobs=N` builds side by side each
// declare the same ExternalDependency, and two crafter-builds can run at
// once. Fetching a clone that already exists ran `git pull` (and the CMake
// build) in it with nothing to keep a second fetch out, and the second git
// fails on the first one's index.lock. A clone's fetch and build now hold a
// lock of their own.
//
// Eight threads fetch the same tracked branch of a local repository at
// once, a few rounds over, the branch moving between rounds so every pull
// has work to do; every fetch has to succeed. Everything is local;
// XDG_CACHE_HOME keeps the clone out of the real cache.
extern "C" int setenv(const char* name, const char* value, int overwrite);
namespace {
std::int32_t failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++failures;
}
}
void Git(const fs::path& dir, std::string_view args) {
const std::string command = std::format("git -C {} {}", ShellQuote(dir.string()), args);
auto result = RunCommandWithTimeout(command, std::chrono::seconds(60));
if (result.exitCode != 0) throw std::runtime_error(std::format("{} failed: {}", command, result.output));
}
void Commit(const fs::path& repo, std::uint32_t round) {
std::ofstream(repo / "round.txt", std::ios::binary | std::ios::trunc) << round;
Git(repo, "add round.txt");
Git(repo, std::format("commit -q -m round-{}", round));
}
}
int main() {
const fs::path scratch = fs::temp_directory_path() / "crafter-build-concurrent-external-fetch";
fs::remove_all(scratch);
fs::create_directories(scratch);
setenv("XDG_CACHE_HOME", (scratch / "cache").c_str(), 1);
setenv("GIT_CONFIG_NOSYSTEM", "1", 1);
setenv("GIT_CONFIG_GLOBAL", "/dev/null", 1);
for (const char* name : { "GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME" }) setenv(name, "crafter-build test", 1);
for (const char* name : { "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL" }) setenv(name, "test@example.invalid", 1);
const fs::path repo = scratch / "shared";
fs::create_directories(repo);
Git(repo, "init -q -b main");
Commit(repo, 0);
const GitSource source { .url = std::format("file://{}", repo.string()), .branch = "main" };
const fs::path clone = GitFetch(source);
constexpr std::uint32_t threads = 8;
constexpr std::uint32_t rounds = 4;
std::vector<std::string> errors;
std::mutex errorsMutex;
for (std::uint32_t round = 1; round <= rounds; ++round) {
Commit(repo, round);
std::vector<std::jthread> fetchers;
for (std::uint32_t i = 0; i < threads; ++i) {
fetchers.emplace_back([&] {
try {
(void)GitFetch(source);
} catch (const std::exception& e) {
std::lock_guard lock(errorsMutex);
errors.push_back(e.what());
}
});
}
}
Check(errors.empty(), std::format("{} fetches at once, {} rounds: every one succeeds ({} failed{}{})", threads, rounds, errors.size(), errors.empty() ? "" : ", the first: ", errors.empty() ? "" : errors.front()));
std::ifstream round(clone / "round.txt");
std::uint32_t last = 0;
round >> last;
Check(last == rounds, std::format("and the clone ends at the last round ({})", last));
std::error_code ec;
fs::remove_all(scratch, ec);
if (failures > 0) {
std::println(std::cerr, "{} assertions failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,89 @@
// 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;
// A CMake dependency's newest library decides when its consumers are stale
// (BuildExternal's latestArtifact). Only files directly in the CMake build
// directory named *.a or *.so were looked at, so msquic's
// bin/Release/libmsquic.so.2, in one of the dependency's libDirs and with a
// version after the .so, never counted: a rebuilt msquic left everything
// built against the old one in place.
//
// The dependency here is a local repository whose CMake project puts a
// versioned shared library in bin/Release, declared as a libDir. Everything
// is local; XDG_CACHE_HOME keeps the clone out of the real cache.
extern "C" int setenv(const char* name, const char* value, int overwrite);
namespace {
std::int32_t failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++failures;
}
}
void Git(const fs::path& dir, std::string_view args) {
const std::string command = std::format("git -C {} {}", ShellQuote(dir.string()), args);
auto result = RunCommandWithTimeout(command, std::chrono::seconds(60));
if (result.exitCode != 0) throw std::runtime_error(std::format("{} failed: {}", command, result.output));
}
void Write(const fs::path& file, std::string_view text) {
std::ofstream(file, std::ios::binary | std::ios::trunc) << text;
}
}
int main() {
const fs::path scratch = fs::temp_directory_path() / "crafter-build-external-artifact-time";
fs::remove_all(scratch);
fs::create_directories(scratch);
setenv("XDG_CACHE_HOME", (scratch / "cache").c_str(), 1);
setenv("GIT_CONFIG_NOSYSTEM", "1", 1);
setenv("GIT_CONFIG_GLOBAL", "/dev/null", 1);
for (const char* name : { "GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME" }) setenv(name, "crafter-build test", 1);
for (const char* name : { "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL" }) setenv(name, "test@example.invalid", 1);
const fs::path repo = scratch / "versioned";
fs::create_directories(repo);
Write(repo / "lib.c", "int Versioned(void) { return 2; }\n");
Write(repo / "CMakeLists.txt",
"cmake_minimum_required(VERSION 3.16)\n"
"project(versioned C)\n"
"add_library(versioned SHARED lib.c)\n"
"set_target_properties(versioned PROPERTIES VERSION 2.0.0 SOVERSION 2\n"
" LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin/Release)\n");
Git(repo, "init -q -b main");
Git(repo, "add .");
Git(repo, "commit -q -m versioned");
ExternalDependency dep;
dep.name = "versioned";
dep.source.url = std::format("file://{}", repo.string());
dep.builder = ExternalBuilder::CMake;
dep.libDirs = { "bin/Release" };
dep.libs = { "versioned" };
std::atomic<bool> cancelled = false;
const ExternalBuildResult result = BuildExternal(dep, HostTarget(), cancelled);
Check(result.error.empty(), std::format("the dependency builds: {}", result.error));
const fs::path library = ExternalCloneDir(dep, HostTarget()) / "build" / "bin" / "Release" / "libversioned.so.2.0.0";
Check(fs::exists(library), std::format("the library is where its CMake project puts it: {}", library.string()));
if (fs::exists(library)) {
Check(result.latestArtifact == fs::last_write_time(library), "its newest artifact is the versioned library in the libDir");
}
std::error_code ec;
fs::remove_all(scratch, ec);
if (failures > 0) {
std::println(std::cerr, "{} assertions failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,128 @@
// 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;
// A fetched dependency's submodules have to match the revision it was fetched
// at. They used to come from the remote's default branch: a fresh clone was
// `git clone --recursive`, which checks out the default branch's submodules,
// and the `switch` or `checkout` to the requested branch or commit that came
// after it left them where they were. A branch that was pulled forward kept
// the submodules of its old tip as well. So pinning msquic to a release built
// that release against the main branch's quictls.
//
// The test makes a repository whose submodule moves between two of its
// commits, then fetches it four ways through GitFetch:
// - pinned to the older commit;
// - by a branch at the older commit;
// - by a branch that is then moved to the newer commit and fetched again;
// - pinned again after the cached clone's submodule was checked out
// elsewhere, as an older crafter-build left it.
// Everything is local; XDG_CACHE_HOME keeps the clones out of the real cache.
extern "C" int setenv(const char* name, const char* value, int overwrite);
namespace {
std::int32_t failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++failures;
}
}
// Runs git in `dir`; the output, or an exception naming the command.
std::string Git(const fs::path& dir, std::string_view args) {
const std::string command = std::format("git -C {} {}", ShellQuote(dir.string()), args);
auto result = RunCommandWithTimeout(command, std::chrono::seconds(60));
if (result.exitCode != 0) throw std::runtime_error(std::format("{} failed: {}", command, result.output));
while (!result.output.empty() && (result.output.back() == '\n' || result.output.back() == '\r')) result.output.pop_back();
return result.output;
}
void Write(const fs::path& file, std::string_view text) {
std::ofstream(file, std::ios::binary | std::ios::trunc) << text;
}
std::string Read(const fs::path& file) {
std::ifstream stream(file, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(stream), std::istreambuf_iterator<char>());
}
}
int main() {
const fs::path scratch = fs::temp_directory_path() / "crafter-build-git-submodules";
fs::remove_all(scratch);
fs::create_directories(scratch);
setenv("XDG_CACHE_HOME", (scratch / "cache").c_str(), 1);
// A known git: no user or system configuration, an identity for the
// commits, and file:// allowed for submodules (git refuses it by default),
// also for the git that GitFetch runs.
setenv("GIT_CONFIG_NOSYSTEM", "1", 1);
setenv("GIT_CONFIG_GLOBAL", "/dev/null", 1);
setenv("GIT_CONFIG_COUNT", "1", 1);
setenv("GIT_CONFIG_KEY_0", "protocol.file.allow", 1);
setenv("GIT_CONFIG_VALUE_0", "always", 1);
for (const char* name : { "GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME" }) setenv(name, "crafter-build test", 1);
for (const char* name : { "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL" }) setenv(name, "test@example.invalid", 1);
// The submodule: "one", then "two".
const fs::path sub = scratch / "sub";
fs::create_directories(sub);
Git(sub, "init -q -b main");
Write(sub / "value.txt", "one");
Git(sub, "add value.txt");
Git(sub, "commit -q -m one");
const std::string one = Git(sub, "rev-parse HEAD");
Write(sub / "value.txt", "two");
Git(sub, "commit -q -am two");
const std::string two = Git(sub, "rev-parse HEAD");
// The superproject: `older` has the submodule at "one", main at "two".
const fs::path super = scratch / "super";
fs::create_directories(super);
Git(super, "init -q -b main");
Git(super, std::format("submodule --quiet add {} lib", ShellQuote(std::format("file://{}", sub.string()))));
Git(super / "lib", std::format("checkout -q {}", one));
Git(super, "add lib");
Git(super, "commit -q -m older");
const std::string older = Git(super, "rev-parse HEAD");
Git(super, "branch older");
Git(super, "branch track");
Git(super / "lib", std::format("checkout -q {}", two));
Git(super, "add lib");
Git(super, "commit -q -m newer");
const std::string url = std::format("file://{}", super.string());
// ── Pinned to the older commit ───────────────────────────────────────────
const fs::path pinned = GitFetch({ .url = url, .commit = older });
Check(Read(pinned / "lib" / "value.txt") == "one", std::format("a commit pin gets that commit's submodule (lib says {})", Read(pinned / "lib" / "value.txt")));
// ── By a branch at the older commit ──────────────────────────────────────
const fs::path branch = GitFetch({ .url = url, .branch = "older" });
Check(Read(branch / "lib" / "value.txt") == "one", std::format("a branch gets its own tip's submodule (lib says {})", Read(branch / "lib" / "value.txt")));
// ── A tracked branch moving forward ──────────────────────────────────────
const fs::path tracked = GitFetch({ .url = url, .branch = "track" });
Check(Read(tracked / "lib" / "value.txt") == "one", "a tracked branch starts with its tip's submodule");
Git(super, "branch -f track main");
const fs::path pulled = GitFetch({ .url = url, .branch = "track" });
Check(pulled == tracked && Read(pulled / "lib" / "value.txt") == "two", std::format("pulling it forward moves the submodule along (lib says {})", Read(pulled / "lib" / "value.txt")));
// ── A cached clone with its submodule out of step ────────────────────────
Git(pinned / "lib", std::format("checkout -q {}", two));
const fs::path repaired = GitFetch({ .url = url, .commit = older });
Check(repaired == pinned && Read(repaired / "lib" / "value.txt") == "one", std::format("a cached clone at the pin gets its submodule put back (lib says {})", Read(repaired / "lib" / "value.txt")));
std::error_code ec;
fs::remove_all(scratch, ec);
if (failures > 0) {
std::println(std::cerr, "{} assertions failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,6 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
int main() {
return 0;
}

View file

@ -0,0 +1,119 @@
// 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;
// A change to what an executable is linked with has to relink it, even when
// no object changed. The relink check compared modification times only, so a
// link flag added in project.cpp, or a dependency's library moving (an
// ExternalDependency whose options changed is fetched and built into a new
// cache directory, and its -L and rpath follow), left the old binary in
// place: Crafter.Network's tests kept loading the msquic they were first
// linked against.
//
// The test adds an rpath to an executable's link flags, takes it away again,
// and then builds once more with nothing changed, which must not relink.
// The flags come out of sets the dependency threads fill as they finish, so
// the recorded command lists them sorted: in the order they happened to land
// in, an unchanged build could look like a changed one.
namespace {
std::int32_t failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++failures;
}
}
constexpr std::string_view marker = "/crafter-build-link-flags-change-marker";
BuildResult BuildOnce(const fs::path& project, std::vector<std::string> linkFlags) {
Configuration app;
app.path = project;
app.name = "linked";
app.outputName = "linked";
app.target = HostTarget();
app.type = ConfigurationType::Executable;
app.linkFlags = std::move(linkFlags);
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
app.GetInterfacesAndImplementations(ifaces, impls);
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
std::mutex depMutex;
BuildResult result = Build(app, depResults, depMutex);
return result;
}
Configuration Plain(const fs::path& project) {
Configuration app;
app.path = project;
app.name = "linked";
app.outputName = "linked";
app.target = HostTarget();
app.type = ConfigurationType::Executable;
return app;
}
fs::path Binary(const fs::path& project) {
return Plain(project).BinDir() / "linked";
}
bool HasMarker(const fs::path& binary) {
std::ifstream stream(binary, std::ios::binary);
const std::string bytes((std::istreambuf_iterator<char>(stream)), std::istreambuf_iterator<char>());
return bytes.find(marker) != std::string::npos;
}
}
int main() {
const fs::path project = fs::temp_directory_path() / "crafter-build-link-flags-change";
fs::remove_all(project);
fs::copy(fs::current_path() / "tests" / "LinkFlagsChange" / "fixture", project, fs::copy_options::recursive);
const std::string rpath = std::format("-Wl,-rpath,{}", marker);
const BuildResult first = BuildOnce(project, {});
Check(first.result.empty(), std::format("the plain build succeeds: {}", first.result));
const fs::path binary = Binary(project);
Check(fs::exists(binary) && !HasMarker(binary), "the plain binary has no rpath to the marker");
const BuildResult added = BuildOnce(project, { rpath });
Check(added.result.empty(), std::format("the build with the rpath succeeds: {}", added.result));
Check(HasMarker(binary), "adding a link flag relinks the binary");
const BuildResult removed = BuildOnce(project, {});
Check(removed.result.empty(), std::format("the build without it succeeds: {}", removed.result));
Check(!HasMarker(binary), "taking it away relinks it again");
const fs::file_time_type linked = fs::last_write_time(binary);
const BuildResult again = BuildOnce(project, {});
Check(again.result.empty() && fs::last_write_time(binary) == linked, "an unchanged build doesn't relink");
const std::vector<std::string> flags = { "-Wl,-z,now", "-Wl,-O1", "-Wl,--as-needed", "-Wl,-z,relro", "-Wl,--build-id", "-Wl,--hash-style=gnu" };
const BuildResult many = BuildOnce(project, flags);
Check(many.result.empty(), std::format("the build with six flags succeeds: {}", many.result));
std::ifstream recorded(Plain(project).BuildDir() / "linked.link", std::ios::binary);
const std::string record((std::istreambuf_iterator<char>(recorded)), std::istreambuf_iterator<char>());
std::vector<std::size_t> at;
for (const std::string& flag : flags) at.push_back(record.find(std::format(" {}", flag)));
std::vector<std::string> sorted = flags;
std::ranges::sort(sorted);
bool inOrder = !std::ranges::contains(at, std::string::npos);
for (std::size_t k = 0; inOrder && k + 1 < sorted.size(); ++k) {
const auto index = [&](const std::string& flag) { return at[static_cast<std::size_t>(std::ranges::find(flags, flag) - flags.begin())]; };
inOrder = index(sorted[k]) < index(sorted[k + 1]);
}
Check(inOrder, std::format("the link record lists the flags sorted: {}", record));
std::error_code ec;
fs::remove_all(project, ec);
if (failures > 0) {
std::println(std::cerr, "{} assertions failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,9 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
int main() {
std::println("hello from crafter-build");
return 0;
}

View file

@ -0,0 +1,97 @@
// 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;
// Building for x86_64-w64-mingw32 without a usable mingw-w64 libstdc++ has to
// fail the build with a message that says what is missing. It used to abort
// the whole process with a bare "libc++abi: terminating": the std module's
// source was found by listing <mingw sysroot>/include/c++ with a throwing
// directory_iterator, the exception unwound through Build() while its worker
// threads were still joinable, and ~thread called std::terminate. That is
// what every mingw build inside the Flatpak did, as the sandbox has no
// /usr/x86_64-w64-mingw32.
//
// The std module's source is now found through libstdc++.modules.json in the
// mingw toolchain's library directories. CRAFTER_MINGW_DIR points the
// toolchain at a scratch prefix, which the test takes through four states:
// - missing;
// - with only a manifest in the prefix's generic lib/, where the host's
// Linux libstdc++ keeps one: not the mingw library, so still missing;
// - with a mingw manifest that names no std module;
// - with one whose std module source isn't there.
extern "C" int setenv(const char* name, const char* value, int overwrite);
namespace {
std::int32_t failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++failures;
}
}
BuildResult BuildOnce(const fs::path& project) {
Configuration app;
app.path = project;
app.name = "hello";
app.outputName = "hello";
app.target = "x86_64-w64-mingw32";
app.type = ConfigurationType::Executable;
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
app.GetInterfacesAndImplementations(ifaces, impls);
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
std::mutex depMutex;
return Build(app, depResults, depMutex);
}
}
int main() {
const fs::path scratch = fs::temp_directory_path() / "crafter-build-mingw-missing";
fs::remove_all(scratch);
fs::create_directories(scratch);
const fs::path project = scratch / "project";
fs::copy(fs::current_path() / "tests" / "MingwMissing" / "fixture", project, fs::copy_options::recursive);
const fs::path sysroot = scratch / "mingw";
// Before anything resolves the toolchain, which reads both once. The std
// module cache goes to the scratch directory too.
setenv("CRAFTER_MINGW_DIR", sysroot.c_str(), 1);
setenv("XDG_CACHE_HOME", (scratch / "cache").c_str(), 1);
const BuildResult missing = BuildOnce(project);
Check(!missing.result.empty(), "a missing mingw prefix fails the build");
Check(missing.result.contains("mingw") && missing.result.contains(sysroot.string()) && missing.result.contains("CRAFTER_MINGW_DIR"), std::format("and says where it looked and how to point elsewhere: {}", missing.result));
auto write = [](const fs::path& file, std::string_view text) {
fs::create_directories(file.parent_path());
std::ofstream(file, std::ios::binary | std::ios::trunc) << text;
};
write(sysroot / "lib" / "libstdc++.modules.json", R"({"version": 1, "modules": [{"logical-name": "std", "source-path": "../include/c++/16/bits/std.cc", "is-std-library": true}]})");
write(sysroot / "include" / "c++" / "16" / "bits" / "std.cc", "// the host's Linux std module\n");
const BuildResult linux = BuildOnce(project);
Check(linux.result.contains("no mingw-w64 libstdc++"), std::format("a manifest outside the mingw toolchain's own directories isn't taken for it: {}", linux.result));
const fs::path manifest = sysroot / "x86_64-w64-mingw32" / "lib" / "libstdc++.modules.json";
write(manifest, R"({"version": 1, "modules": [{"logical-name": "std.compat", "source-path": "../include/c++/15.1.0/bits/std.compat.cc"}]})");
const BuildResult noStd = BuildOnce(project);
Check(noStd.result.contains(manifest.string()) && noStd.result.contains("no std module"), std::format("a manifest without the std module fails the build, naming the manifest: {}", noStd.result));
write(manifest, R"({"version": 1, "modules": [{"source-path": "../include/c++/15.1.0/bits/std.cc", "logical-name": "std"}]})");
const BuildResult noSource = BuildOnce(project);
Check(noSource.result.contains((sysroot / "x86_64-w64-mingw32" / "include" / "c++" / "15.1.0" / "bits" / "std.cc").string()) && noSource.result.contains("missing"),
std::format("a std module source that isn't there fails the build, naming it: {}", noSource.result));
std::error_code ec;
fs::remove_all(scratch, ec);
if (failures > 0) {
std::println(std::cerr, "{} assertions failed", failures);
return 1;
}
return 0;
}

View file

@ -27,7 +27,7 @@ int main() {
Shader shader(fs::path(src), "main", ShaderType::Fragment);
std::array<fs::path, 0> includeDirs = {};
std::string err = shader.Compile(outDir, includeDirs);
std::string err = shader.Compile(outDir, outDir, includeDirs);
if (!err.empty()) {
std::println(std::cerr, "shader compile failed: {}", err);
return 1;
@ -51,10 +51,27 @@ int main() {
}
// Compiling again should be a no-op since the source isn't newer.
if (!shader.Check(outDir)) {
if (!shader.Check(outDir, outDir, includeDirs)) {
std::println(std::cerr, "Shader::Check returned false on an up-to-date spv");
return 1;
}
// Searched elsewhere, an #include could find another file: stale.
const std::array<fs::path, 1> elsewhere = { outDir / "elsewhere" };
if (shader.Check(outDir, outDir, elsewhere)) {
std::println(std::cerr, "Shader::Check called an spv up to date for other include directories");
return 1;
}
// So could a source of the same name moved to another directory, which
// its #includes are searched from first.
const fs::path movedDir = outDir / "moved";
fs::create_directories(movedDir);
fs::copy_file(src, movedDir / src.filename());
fs::last_write_time(movedDir / src.filename(), fs::last_write_time(src));
Shader moved(movedDir / src.filename(), "main", ShaderType::Fragment);
if (moved.Check(outDir, outDir, includeDirs)) {
std::println(std::cerr, "Shader::Check called an spv up to date for a source of the same name elsewhere");
return 1;
}
// One source registered twice: the second entry carries defines and a
// variant name. It must land in its own file instead of overwriting the
@ -77,16 +94,16 @@ int main() {
// exists, the variant is still due. A Check() that ignored the
// variant would call it up to date here, and Build() would then
// never compile a variant added to an already built project.
if (each == &red && red.Check(outDir)) {
if (each == &red && red.Check(outDir, outDir, includeDirs)) {
std::println(std::cerr, "Shader::Check called the variant up to date before it was ever compiled");
return 1;
}
std::string variantErr = each->Compile(outDir, includeDirs);
std::string variantErr = each->Compile(outDir, outDir, includeDirs);
if (!variantErr.empty()) {
std::println(std::cerr, "variant shader compile failed: {}", variantErr);
return 1;
}
if (!each->Check(outDir)) {
if (!each->Check(outDir, outDir, includeDirs)) {
std::println(std::cerr, "Shader::Check returned false for {}", each->OutputName().string());
return 1;
}
@ -99,14 +116,14 @@ int main() {
Shader malformed(fs::path(variantSrc), "main", ShaderType::Fragment);
malformed.defines = {bad};
malformed.variant = "malformed";
if (!malformed.Compile(outDir, includeDirs).contains("invalid shader define")) {
if (!malformed.Compile(outDir, outDir, includeDirs).contains("invalid shader define")) {
std::println(std::cerr, "a malformed define was not rejected by name");
return 1;
}
}
Shader nested(fs::path(variantSrc), "main", ShaderType::Fragment);
nested.variant = "sub/red";
if (!nested.Compile(outDir, includeDirs).contains("path separator")) {
if (!nested.Compile(outDir, outDir, includeDirs).contains("path separator")) {
std::println(std::cerr, "a variant with a path separator was not rejected by name");
return 1;
}

View file

@ -0,0 +1 @@
const vec4 kTint = vec4(0.0, 1.0, 0.0, 1.0);

View file

@ -0,0 +1,16 @@
#version 450
#extension GL_GOOGLE_include_directive : require
// Built by the ShaderIncludeChange test, which edits palette.glsl and this
// entry's defines between builds.
#include "palette.glsl"
layout(location = 0) out vec4 outColor;
void main() {
#ifdef LIT_DIM
outColor = kTint * 0.5;
#else
outColor = kTint;
#endif
}

View file

@ -0,0 +1,6 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
int main() {
return 0;
}

View file

@ -0,0 +1,8 @@
#version 450
// Includes nothing: the ShaderIncludeChange test checks it is left alone.
layout(location = 0) out vec4 outColor;
void main() {
outColor = vec4(0.0, 0.0, 1.0, 1.0);
}

View file

@ -0,0 +1,126 @@
// 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;
// A shader's output is stale when anything that went into it changed, not
// only its own source. Check() used to compare the .spv against the source's
// mtime alone, so:
// - editing a file the shader #includes left the .spv built against the old
// contents, the GLSL twin of IncrementalHeaderChange;
// - changing an entry's defines without touching the source did too.
// Each pass changes one thing and checks both directions: the shader it
// reaches is recompiled, to new contents, and the shader that includes
// nothing is left alone. A build with nothing changed compiles nothing.
namespace {
std::int32_t failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++failures;
}
}
// The fixture is edited between passes, so work on a copy outside the repo.
fs::path StageFixture() {
fs::path source = fs::current_path() / "tests" / "ShaderIncludeChange" / "fixture";
fs::path staged = fs::temp_directory_path() / "crafter-build-shader-include-change";
fs::remove_all(staged);
fs::copy(source, staged, fs::copy_options::recursive);
return staged;
}
Configuration MakeApp(const fs::path& staged) {
Configuration app;
app.path = staged;
app.name = "lit";
app.outputName = "lit";
app.target = HostTarget();
app.type = ConfigurationType::Executable;
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
app.GetInterfacesAndImplementations(ifaces, impls);
app.shaders.emplace_back(staged / "lit.frag", std::string("main"), ShaderType::Fragment);
app.shaders.emplace_back(staged / "plain.frag", std::string("main"), ShaderType::Fragment);
// Puts include/ on the shaders' #include path.
app.buildFiles = { staged / "include" / "palette.glsl" };
return app;
}
bool BuildOk(Configuration& app, std::string_view label) {
// A fresh depResults per pass: the map memoizes a Configuration's
// build for the duration of one pass.
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
std::mutex depMutex;
BuildResult r = Build(app, depResults, depMutex);
Check(r.result.empty(), std::format("{} builds: {}", label, r.result));
return r.result.empty();
}
std::string ReadAll(const fs::path& file) {
std::ifstream stream(file, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(stream), std::istreambuf_iterator<char>());
}
}
int main() {
const fs::path staged = StageFixture();
Configuration app = MakeApp(staged);
const fs::path lit = app.BinDir() / "lit.spv";
const fs::path plain = app.BinDir() / "plain.spv";
const fs::path palette = staged / "include" / "palette.glsl";
// Pass 1: everything from scratch.
if (!BuildOk(app, "the first pass")) return 1;
Check(fs::exists(lit) && fs::exists(plain), "both shaders are in the bin dir");
const fs::file_time_type plainBuilt = fs::last_write_time(plain);
const std::string green = ReadAll(lit);
// Pass 2: nothing changed.
fs::file_time_type litBuilt = fs::last_write_time(lit);
BuildOk(app, "an unchanged project");
Check(fs::last_write_time(lit) == litBuilt && fs::last_write_time(plain) == plainBuilt, "with nothing changed, no shader is recompiled");
// Pass 3: the included file changes. Dated just after the output it must
// outdate, so the rebuilt output is newer than it again.
{
std::ofstream out(palette, std::ios::trunc);
out << "const vec4 kTint = vec4(1.0, 0.0, 0.0, 1.0);\n";
}
fs::last_write_time(palette, litBuilt + std::chrono::milliseconds(1));
BuildOk(app, "an edited include");
const std::string red = ReadAll(lit);
Check(fs::last_write_time(lit) > litBuilt && red != green, "editing an included file recompiles the shader that includes it, to new contents");
Check(fs::last_write_time(plain) == plainBuilt, "and leaves the shader that includes nothing alone");
// Pass 4: the entry's defines change, no file does.
app.shaders[0].defines = { "LIT_DIM" };
litBuilt = fs::last_write_time(lit);
BuildOk(app, "a new define");
const std::string dim = ReadAll(lit);
Check(fs::last_write_time(lit) > litBuilt && dim != red, "a define added to the entry recompiles it, taking the #ifdef branch");
Check(fs::last_write_time(plain) == plainBuilt, "and leaves the other entry alone");
// Pass 5: and back.
app.shaders[0].defines = {};
BuildOk(app, "the define taken out");
Check(ReadAll(lit) == red, "taking the define out again recompiles it as it was");
// Pass 6: nothing changed again; what the passes above recorded holds.
litBuilt = fs::last_write_time(lit);
BuildOk(app, "an unchanged project, again");
Check(fs::last_write_time(lit) == litBuilt && fs::last_write_time(plain) == plainBuilt, "with nothing changed since, no shader is recompiled");
std::error_code ec;
fs::remove_all(staged, ec);
if (failures > 0) {
std::println(std::cerr, "{} assertions failed", failures);
return 1;
}
return 0;
}

View file

@ -61,7 +61,7 @@ int main() {
Check(tc.libcxxVersion == LibcxxVersion("/usr/include/c++/v1"), "the system libc++ headers state the release of the system module source");
Check(tc.stdModulesDir.empty(), "no crafter-build prefix, no shipped module sources");
Check(tc.wasiSysroot == "/usr/share/wasi-sysroot", "the system wasi sysroot is the default");
Check(tc.mingwSysroot == "/usr/x86_64-w64-mingw32" && !tc.mingwSysrootExplicit, "the default mingw dir is not passed as --sysroot");
Check(tc.mingwSysroot.empty(), "outside a sandbox, clang finds mingw-w64 itself: no --sysroot");
Check(tc.LibcxxCompileFlags().empty() && tc.LibcxxLinkFlags().empty(), "the PATH fallback adds no libc++ flags");
}
{
@ -144,7 +144,21 @@ int main() {
in.env["CRAFTER_MINGW_DIR"] = "/opt/mingw";
Toolchain tc = ResolveToolchain(in);
Check(tc.wasiSysroot == "/opt/wasi", "CRAFTER_BUILD_WASI_SYSROOT overrides the wasi sysroot");
Check(tc.mingwSysroot == "/opt/mingw" && tc.mingwSysrootExplicit, "CRAFTER_MINGW_DIR overrides the mingw dir and is passed as --sysroot");
Check(tc.mingwSysroot == "/opt/mingw", "CRAFTER_MINGW_DIR names the mingw prefix passed as --sysroot");
}
{
// Sandboxed, the host's /usr stands in for the missing mingw-w64,
// unless CRAFTER_MINGW_DIR says otherwise.
const fs::path host = fs::temp_directory_path() / "crafter-build-toolchain-host";
fs::remove_all(host);
ToolchainInputs in;
in.hostRoot = host;
Check(ResolveToolchain(in).mingwSysroot.empty(), "a host without mingw-w64 gives no --sysroot");
fs::create_directories(host / "usr" / "x86_64-w64-mingw32");
Check(ResolveToolchain(in).mingwSysroot == host / "usr", "a host with mingw-w64 gives its /usr as the mingw prefix");
in.env["CRAFTER_MINGW_DIR"] = "/opt/mingw";
Check(ResolveToolchain(in).mingwSysroot == "/opt/mingw", "CRAFTER_MINGW_DIR wins over the host's");
fs::remove_all(host);
}
{
// The cache key separates toolchains that would build different