Merge pull request 'Decide a build step by its exit code, not by whether it printed' (#33) from claude/issue-32 into master
All checks were successful
CI / build-test-release (push) Successful in 7m42s

This commit is contained in:
catbot 2026-08-26 00:52:58 +00:00
commit 802caac1c2
14 changed files with 290 additions and 64 deletions

View file

@ -134,6 +134,8 @@ Everything that changes what gets built belongs in the variant id, since it name
`crafter-build clean` removes the project's `bin/` and `build/` trees. It doesn't load `project.cpp`, so it still works when the project no longer compiles.
A build step fails when its command exits non-zero — never because it printed something. Warnings are printed to stderr and the build carries on; nothing here implies `-Werror`, so add it to `cfg.compileFlags` if you want warnings to be fatal, and they will then be fatal every time. Note that a warning only appears when the unit it belongs to is actually recompiled, so a build with nothing to do is silent.
## Tests
Tests live under `tests/<Name>/`. The simplest case is a single C++ file:

View file

@ -788,7 +788,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
Progress::Task task(std::format("Compiling {}.c", cFile.filename().string()));
if (buildCancelled.load(std::memory_order_relaxed)) return;
std::string result = RunCommand(std::format("clang {0}.c --target={1}{2} -O3{3} -c{4}{5}{6} -MD -MF {7}_source.o.d -o {7}_source.o", cFile.string(), config.target, cArchFlags, ltoCompileFlags, includeFlags, defineFlags, userFlags, (buildDir / cFile.filename()).string()));
std::string result = RunBuildCommand(std::format("clang {0}.c --target={1}{2} -O3{3} -c{4}{5}{6} -MD -MF {7}_source.o.d -o {7}_source.o", cFile.string(), config.target, cArchFlags, ltoCompileFlags, includeFlags, defineFlags, userFlags, (buildDir / cFile.filename()).string()));
if (result.empty()) return;
bool expected = false;
@ -812,7 +812,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
Progress::Task task(std::format("Compiling {}.cu", cFile.filename().string()));
if (buildCancelled.load(std::memory_order_relaxed)) return;
std::string result = RunCommand(std::format("nvcc {}.cu -c -o {}_source.o -O3 -arch=sm_89", cFile.string(), (buildDir / cFile.filename()).string()));
std::string result = RunBuildCommand(std::format("nvcc {}.cu -c -o {}_source.o -O3 -arch=sm_89", cFile.string(), (buildDir / cFile.filename()).string()));
if (result.empty()) return;
bool expected = false;
@ -1165,9 +1165,9 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
}
}
if (config.target.starts_with("wasm32")) {
buildResult.result = RunCommand(std::format("{}{} -o {}.wasm -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
buildResult.result = RunBuildCommand(std::format("{}{} -o {}.wasm -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
} else {
buildResult.result = RunCommand(std::format("{}{} -o {} -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
buildResult.result = RunBuildCommand(std::format("{}{} -o {} -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
}
#endif
@ -1197,10 +1197,10 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
};
for (Configuration* dep : config.dependencies) copyDepDlls(dep);
buildResult.result = RunCommand(std::format("{}{} -o {} -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
buildResult.result = RunBuildCommand(std::format("{}{} -o {} -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
} else {
std::system(std::format("copy \"%LIBCXX_DIR%\\lib\\c++.dll\" \"{}\\c++.dll\"", outputDir.string()).c_str());
buildResult.result = RunCommand(std::format("{}{} -o {}.exe -fuse-ld=lld -L %LIBCXX_DIR%\\lib -lc++ -nostdinc++ -nostdlib++{}", command, files, (outputDir/config.outputName).string(), linkExtras));
buildResult.result = RunBuildCommand(std::format("{}{} -o {}.exe -fuse-ld=lld -L %LIBCXX_DIR%\\lib -lc++ -nostdinc++ -nostdlib++{}", command, files, (outputDir/config.outputName).string(), linkExtras));
}
#endif
} else if(config.type == ConfigurationType::LibraryStatic) {
@ -1208,11 +1208,11 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// ThinLTO emits LLVM bitcode objects; plain `ar` writes an archive
// index that omits their symbols, so a consumer's lld link can't
// pull the needed members. llvm-ar writes a bitcode-aware index.
buildResult.result = RunCommand(std::format("{} rcs {}.a {}", useLto ? "llvm-ar" : "ar", (outputDir/fs::path(std::string("lib")+config.outputName)).string(), files));
buildResult.result = RunBuildCommand(std::format("{} rcs {}.a {}", useLto ? "llvm-ar" : "ar", (outputDir/fs::path(std::string("lib")+config.outputName)).string(), files));
#endif
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
buildResult.result = RunCommand(std::format("llvm-lib.exe {} /OUT:{}.lib", files, (outputDir/fs::path(config.outputName)).string()));
buildResult.result = RunBuildCommand(std::format("llvm-lib.exe {} /OUT:{}.lib", files, (outputDir/fs::path(config.outputName)).string()));
#endif
} else {
// LibraryDynamic. Output names follow each target's convention so
@ -1223,13 +1223,13 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
if (config.target == "x86_64-w64-mingw32") {
fs::path dll = outputDir / std::format("{}.dll", config.outputName);
fs::path implib = outputDir / std::format("lib{}.dll.a", config.outputName);
buildResult.result = RunCommand(std::format("{}{} -shared -o {} -Wl,--out-implib,{} -fuse-ld=lld{}", command, files, dll.string(), implib.string(), linkExtras));
buildResult.result = RunBuildCommand(std::format("{}{} -shared -o {} -Wl,--out-implib,{} -fuse-ld=lld{}", command, files, dll.string(), implib.string(), linkExtras));
} else if (config.target == "x86_64-pc-windows-msvc") {
fs::path dll = outputDir / std::format("{}.dll", config.outputName);
fs::path implib = outputDir / std::format("{}.lib", config.outputName);
buildResult.result = RunCommand(std::format("{}{} -shared -o {} -Wl,/IMPLIB:{} -fuse-ld=lld{}", command, files, dll.string(), implib.string(), linkExtras));
buildResult.result = RunBuildCommand(std::format("{}{} -shared -o {} -Wl,/IMPLIB:{} -fuse-ld=lld{}", command, files, dll.string(), implib.string(), linkExtras));
} else {
buildResult.result = RunCommand(std::format("{}{} -shared -o {}.so -Wl,-rpath,'$ORIGIN' -fuse-ld=lld{}", command, files, (outputDir/(std::string("lib")+config.outputName)).string(), linkExtras));
buildResult.result = RunBuildCommand(std::format("{}{} -shared -o {}.so -Wl,-rpath,'$ORIGIN' -fuse-ld=lld{}", command, files, (outputDir/(std::string("lib")+config.outputName)).string(), linkExtras));
}
}
}

View file

@ -52,7 +52,7 @@ namespace Crafter {
}
// -MD leaves <name>_impl.o.d beside the object for Check to read.
std::string result = RunCommand(std::format("{0} {1}.cpp -c -MD -MF {2}_impl.o.d -o {2}_impl.o", clang, path.string(), (buildDir/path.filename()).string()));
std::string result = RunBuildCommand(std::format("{0} {1}.cpp -c -MD -MF {2}_impl.o.d -o {2}_impl.o", clang, path.string(), (buildDir/path.filename()).string()));
bool expected = false;
if(!result.empty() && buildCancelled.compare_exchange_strong(expected, true)) {

View file

@ -79,7 +79,7 @@ namespace Crafter {
// -MD records every header the preamble pulled in, next to the BMI as
// <name>.pcm.d, so the next Check can see an edit to one of them.
std::string result = RunCommand(std::format("{0} {1}.cppm --precompile -MD -MF {2}.pcm.d -o {2}.pcm", clang, path.string(), (pcmDir/path.filename()).string()));
std::string result = RunBuildCommand(std::format("{0} {1}.cppm --precompile -MD -MF {2}.pcm.d -o {2}.pcm", clang, path.string(), (pcmDir/path.filename()).string()));
if (!result.empty()) {
bool expected = false;
@ -94,7 +94,7 @@ namespace Crafter {
compiled.store(true);
compiled.notify_all();
result = RunCommand(std::format("{} -Wno-unused-command-line-argument {}.pcm -c -o {}.o", clang, (pcmDir/path.filename()).string(), (buildDir/path.filename()).string()));
result = RunBuildCommand(std::format("{} -Wno-unused-command-line-argument {}.pcm -c -o {}.o", clang, (pcmDir/path.filename()).string(), (buildDir/path.filename()).string()));
if (!result.empty()) {
bool expected = false;
@ -203,7 +203,7 @@ namespace Crafter {
// -MD records every header the preamble pulled in, next to the BMI as
// <name>.pcm.d, so the next Check can see an edit to one of them.
std::string result = RunCommand(std::format("{0} {1}.cppm --precompile -MD -MF {2}.pcm.d -o {2}.pcm", clang, path.string(), (pcmDir/path.filename()).string()));
std::string result = RunBuildCommand(std::format("{0} {1}.cppm --precompile -MD -MF {2}.pcm.d -o {2}.pcm", clang, path.string(), (pcmDir/path.filename()).string()));
if (!result.empty()) {
bool expected = false;
@ -218,7 +218,7 @@ namespace Crafter {
compiled.store(true);
compiled.notify_all();
result = RunCommand(std::format("{} -Wno-unused-command-line-argument {}.pcm -c -o {}.o", clang, (pcmDir/path.filename()).string(), (buildDir/path.filename()).string()));
result = RunBuildCommand(std::format("{} -Wno-unused-command-line-argument {}.pcm -c -o {}.o", clang, (pcmDir/path.filename()).string(), (buildDir/path.filename()).string()));
if (!result.empty()) {
bool expected = false;

View file

@ -259,28 +259,34 @@ bool Crafter::MatchAny(std::span<const std::string> globs, std::string_view name
return false;
}
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
std::string Crafter::RunCommand(const std::string_view cmd) {
// See the declaration in Crafter.Build-Platform.cppm for why this branches on
// the exit code rather than on whether the command said anything.
std::string Crafter::RunBuildCommand(std::string_view cmd) {
Progress::EchoCommand(cmd);
std::array<char, 128> buffer;
std::string result;
CommandResult r = RunCommandChecked(cmd);
// Use cmd.exe to interpret redirection
std::string with = std::format("cmd /C \"{} 2>&1\"", std::string(cmd));
FILE* pipe = _popen(with.c_str(), "r");
if (!pipe) {
throw std::runtime_error("_popen() failed!");
if (r.exitCode == 0) {
// Warnings from a step that succeeded. Show them — until now they were
// invisible on an incremental build and fatal on a cold one.
if (!r.output.empty()) Progress::Diagnostic(r.output);
return "";
}
while (fgets(buffer.data(), static_cast<std::int32_t>(buffer.size()), pipe) != nullptr) {
result += buffer.data();
// A compiler killed outright (the OOM killer is the common one) prints
// nothing, so the old "output means failure" rule read it as success and
// let the build carry on with a missing object. Say what happened instead.
if (r.output.empty()) {
return r.crashed
? std::format("terminated by signal {}: {}", r.signal, cmd)
: std::format("exited with code {}: {}", r.exitCode, cmd);
}
_pclose(pipe);
return result;
if (r.crashed) {
return std::format("{}\nterminated by signal {}", r.output, r.signal);
}
return r.output;
}
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
CommandResult Crafter::RunCommandChecked(std::string_view cmd) {
std::array<char, 128> buffer;
CommandResult result{};
@ -429,7 +435,7 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
CacheLock lock(stdPcm.parent_path());
if(!fs::exists(stdPcm) || fs::last_write_time(stdPcm) < fs::last_write_time(stdcppm)) {
return RunCommand(std::format("clang++ --target={} -march={} -mtune={} -isystem %LIBCXX_DIR%\\include\\c++\\v1 -nostdinc++ -nostdlib++ -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile %LIBCXX_DIR%\\modules\\c++\\v1\\std.cppm -o {}", config.target, config.march, config.mtune, stdPcm.string()));
return RunBuildCommand(std::format("clang++ --target={} -march={} -mtune={} -isystem %LIBCXX_DIR%\\include\\c++\\v1 -nostdinc++ -nostdlib++ -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile %LIBCXX_DIR%\\modules\\c++\\v1\\std.cppm -o {}", config.target, config.march, config.mtune, stdPcm.string()));
}
return "";
}
@ -521,7 +527,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
cacheDir.string(),
absProject.string(), crafterBuildLib.string(), dllPath.string());
std::string result = RunCommand(compileCmd);
std::string result = RunBuildCommand(compileCmd);
if (!result.empty()) {
throw std::runtime_error(std::format("Failed to compile project {}: {}", absProject.string(), result));
}
@ -597,7 +603,7 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
if (fs::exists(stdPcm) && fs::last_write_time(stdPcm) >= fs::last_write_time(stdcppm)) {
return "";
}
return RunCommand(std::format(
return RunBuildCommand(std::format(
"clang++ --target={} -march={} -mtune={} -isystem %LIBCXX_DIR%\\include\\c++\\v1 "
"-nostdinc++ -nostdlib++ -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier "
"--precompile %LIBCXX_DIR%\\modules\\c++\\v1\\std.cppm -o {}",
@ -613,17 +619,17 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
if (fs::exists(stdPcm) && fs::last_write_time(stdPcm) >= fs::last_write_time(stdCc)) {
return "";
}
// Copy std.cc → std.cppm in C++ rather than via cmd's `copy /Y` because
// `copy` always prints "1 file(s) copied." to stdout and RunCommand
// treats any output as an error. Held under the cache lock, so a plain
// filename is safe — no other builder writes this path concurrently.
// Copy std.cc → std.cppm in C++ rather than via cmd's `copy /Y`: keeping
// it in-process means one less shell round-trip and a std::error_code we
// can report directly. Held under the cache lock, so a plain filename is
// safe — no other builder writes this path concurrently.
fs::path stdCppm = stdPcm.parent_path() / "std.cppm";
std::error_code ec;
fs::copy_file(stdCc, stdCppm, fs::copy_options::overwrite_existing, ec);
if (ec) {
return std::format("copy {} -> {}: {}", stdCc.string(), stdCppm.string(), ec.message());
}
return RunCommand(std::format(
return RunBuildCommand(std::format(
"clang++ --target={} -march={} -mtune={} "
"--sysroot=\"{}\" -femulated-tls "
"-O3 -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier "
@ -730,7 +736,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
absProject.string(), dllPath.string(),
hostExe.parent_path().string());
std::string result = RunCommand(compileCmd);
std::string result = RunBuildCommand(compileCmd);
if (!result.empty()) {
throw std::runtime_error(std::format("Failed to compile project {}: {}", absProject.string(), result));
}
@ -761,26 +767,6 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
std::string Crafter::RunCommand(const std::string_view cmd) {
Progress::EchoCommand(cmd);
std::array<char, 128> buffer;
std::string result;
std::string with = std::format("{} 2>&1", cmd);
// Open pipe to file
FILE* pipe = popen(with.c_str(), "r");
if (!pipe) throw std::runtime_error("popen() failed!");
// Read till end of process:
while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) {
result += buffer.data();
}
// Close pipe
pclose(pipe);
return result;
}
CommandResult Crafter::RunCommandChecked(std::string_view cmd) {
std::array<char, 128> buffer;
CommandResult result{};
@ -874,7 +860,7 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
if (copyEc) {
return std::format("copy {} -> {}: {}", stdCc.string(), stdCppm.string(), copyEc.message());
}
return RunCommand(std::format("clang++ --target={} -march={} -mtune={} -femulated-tls -O3 -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", config.target, config.march, config.mtune, stdCppm.string(), stdPcm.string()));
return RunBuildCommand(std::format("clang++ --target={} -march={} -mtune={} -femulated-tls -O3 -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", config.target, config.march, config.mtune, stdCppm.string(), stdPcm.string()));
} else {
return "";
}
@ -922,7 +908,7 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
archFlags += std::format(" -nostdinc++ -isystem {}/usr/include/c++/v1", config.sysroot);
}
if(!fs::exists(stdPcm) || fs::last_write_time(stdPcm) < fs::last_write_time(stdCppm)) {
return RunCommand(std::format("clang++ --target={} -std=c++26 -stdlib=libc++{}{} -O3 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", config.target, sysrootFlag, archFlags, stdCppm, stdPcm.string()));
return RunBuildCommand(std::format("clang++ --target={} -std=c++26 -stdlib=libc++{}{} -O3 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", config.target, sysrootFlag, archFlags, stdCppm, stdPcm.string()));
} else {
return "";
}
@ -1038,7 +1024,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
cacheDir.string(),
absProject.string(), soPath.string());
std::string result = RunCommand(compileCmd);
std::string result = RunBuildCommand(compileCmd);
if (!result.empty()) {
throw std::runtime_error(std::format("Failed to compile project {}: {}", absProject.string(), result));
}

View file

@ -127,6 +127,18 @@ void EchoCommand(std::string_view command) {
}
}
void Diagnostic(std::string_view text) {
std::lock_guard lock(StateMutex);
if (ActiveVerbosity == Verbosity::Quiet) return;
ClearLineLocked();
// Written whole under StateMutex: parallel compiles hand us one warning
// block each, and interleaving them line-by-line would make every
// "note: ..." continuation follow the wrong diagnostic.
std::fwrite(text.data(), 1, text.size(), stderr);
if (!text.ends_with('\n')) std::fputc('\n', stderr);
std::fflush(stderr);
}
void Clear() {
std::lock_guard lock(StateMutex);
ClearLineLocked();

View file

@ -18,8 +18,18 @@ namespace Crafter {
};
std::string BuildStdPcm(const Configuration& config, fs::path stdPcm);
fs::path GetCacheDir();
std::string RunCommand(const std::string_view command);
CommandResult RunCommandChecked(std::string_view command);
// A compile / link / archive step. Success is the command's exit status,
// never whether it printed anything: a warning does not stop the compiler
// from writing a valid object, and warnings are only emitted when a
// translation unit is actually recompiled — so keying failure off the
// output made the same unchanged source pass or fail depending on whether
// its object happened to be up to date.
//
// Returns an empty string on success (any warnings go to the user via
// Progress::Diagnostic), otherwise the compiler's diagnostics. Callers
// therefore keep the `if (!result.empty())` shape they already had.
std::string RunBuildCommand(std::string_view command);
export CRAFTER_API CommandResult RunCommandWithTimeout(std::string_view command, std::chrono::seconds timeout);
std::string GetBaseCommand(const Configuration& config);
export CRAFTER_API Configuration LoadProject(const fs::path& projectFile, std::span<const std::string_view> args);

View file

@ -31,6 +31,11 @@ export namespace Crafter::Progress {
// Verbose-mode command echo. No-op outside Verbose.
CRAFTER_API void EchoCommand(std::string_view command);
// Non-fatal output from a build step that succeeded — compiler and linker
// warnings. Erases the status line first so the text doesn't land on top of
// it, then writes to stderr. No-op on Quiet.
CRAFTER_API void Diagnostic(std::string_view text);
// Erase the in-place status line so subsequent stderr writes (errors,
// banners) don't collide with it. No-op when not in TTY-redraw mode.
CRAFTER_API void Clear();

View file

@ -110,6 +110,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.AddTest("IncrementalInterfaceChange").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("TransitiveInterfaceChange").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("IncrementalHeaderChange").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("CompilerWarning").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("CleanProject").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("ShaderCompile").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("StandardArgs").Dependencies({ CrafterBuildLib.get() });

View file

@ -0,0 +1,9 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#warning "implementation unit is noisy"
module Widget;
import std;
std::int32_t WidgetValue() { return 42; }

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
// Every source in this fixture carries a #warning: -W#warnings is on by
// default, so each unit produces diagnostics on stderr and still exits 0.
// That is the whole point — the build has to read the exit status, not the
// chatter.
#warning "interface unit is noisy"
export module Widget;
import std;
export std::int32_t WidgetValue();

View file

@ -0,0 +1,8 @@
/* SPDX-License-Identifier: LGPL-3.0-only
SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® */
#warning "C source is noisy"
long CounterValue(void) {
return 7;
}

View file

@ -0,0 +1,17 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#warning "consumer is noisy"
import std;
import Widget;
extern "C" long CounterValue();
// Prints "<module value> <C value>". The test asserts on it so that "the build
// succeeded" also means the objects behind those two numbers were really
// produced, not merely left over from an earlier pass.
int main() {
std::print("{} {}", WidgetValue(), CounterValue());
return 0;
}

View file

@ -0,0 +1,163 @@
// 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 compiler warning must not fail the build. Success used to be inferred from
// whether the command printed anything, and a warning is printing something —
// so a translation unit that warns failed the build the moment it was compiled
// and passed on every run after that, because warnings are only emitted when
// something actually recompiles. Same source, opposite verdicts, decided by
// whether an object file happened to be up to date: it read as a flaky test,
// and a cold CI checkout turned every latent warning in a project into a wall
// of unrelated failures.
//
// The fixture puts a #warning in each kind of source the build knows how to
// compile — module interface, module implementation, C, consumer — and a
// linker warning on top, then asserts the build succeeds cold, succeeds when
// there is nothing to do, and succeeds again after each source is touched back
// into staleness. The last pass introduces a real error, because "never fails"
// would satisfy everything above just as well.
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 mutated during the run, and every assertion here depends
// on units compiling for the first time, so work on a fresh copy outside
// the repo rather than against whatever an earlier run left behind.
fs::path StageFixture() {
fs::path source = fs::current_path() / "tests" / "CompilerWarning" / "fixture";
fs::path staged = fs::temp_directory_path() / "crafter-build-compiler-warning";
fs::remove_all(staged);
fs::copy(source, staged, fs::copy_options::recursive);
return staged;
}
std::unique_ptr<Configuration> MakeLib(const fs::path& staged) {
auto lib = std::make_unique<Configuration>();
lib->path = staged / "lib";
lib->name = "noisy-widget";
lib->outputName = "noisy-widget";
lib->target = HostTarget();
lib->type = ConfigurationType::LibraryStatic;
std::array<fs::path, 1> ifaces = { "Widget" };
std::array<fs::path, 1> impls = { "Widget" };
lib->GetInterfacesAndImplementations(ifaces, impls);
// cFiles are resolved against the cwd at build time, so spell it out.
lib->cFiles = { staged / "lib" / "counter" };
return lib;
}
Configuration MakeApp(const fs::path& staged, Configuration* lib) {
Configuration app;
app.path = staged;
app.name = "noisy-app";
app.outputName = "noisy-app";
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.dependencies = { lib };
// The link step reads a command's output the same way a compile does,
// so cover it too: an unknown -z value makes ld.lld warn and exit 0.
// GNU-style only — lld-link spells its options differently — so the
// link case rides along on hosts that use it and is skipped elsewhere.
if (!app.target.contains("windows")) {
app.linkFlags = { "-Wl,-z,crafter-build-nonexistent-z-value" };
}
return app;
}
BuildResult BuildOnce(Configuration& app) {
// A fresh depResults per pass: the map memoizes each Configuration's
// build for the duration of one pass, so reusing it would skip the
// library's rebuild entirely.
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
std::mutex depMutex;
return Build(app, depResults, depMutex);
}
bool BuildOk(Configuration& app, std::string_view label) {
BuildResult r = BuildOnce(app);
if (!r.result.empty()) {
std::println(std::cerr, "FAIL: {} build failed: {}", label, r.result);
++Failures;
return false;
}
return true;
}
void CheckRun(const fs::path& binary, std::string_view label) {
auto r = RunCommandWithTimeout(binary.string(), std::chrono::seconds(30));
Check(r.exitCode == 0 && !r.crashed && !r.timedOut && r.output == "42 7", std::format("{}: expected '42 7', got '{}' (exit={})", label, r.output, r.exitCode));
}
void Touch(const fs::path& source) {
fs::last_write_time(source, fs::file_time_type::clock::now());
}
}
int main() {
fs::path staged = StageFixture();
std::unique_ptr<Configuration> lib = MakeLib(staged);
Configuration app = MakeApp(staged, lib.get());
fs::path binary = app.BinDir() / "noisy-app";
// Cold: every unit compiles, so every #warning in the fixture is emitted
// and the linker warns as well. This is the pass that used to fail.
if (!BuildOk(app, "cold")) {
std::println(std::cerr, "{} assertions failed", Failures);
return 1;
}
CheckRun(binary, "cold");
// Nothing to recompile, so nothing warns. Same sources as the pass above,
// and the verdict has to match it.
if (BuildOk(app, "idle")) {
CheckRun(binary, "idle");
}
// Back into staleness one source at a time: whichever compile path a
// warning comes out of, it stays non-fatal.
for (auto [source, label] : std::initializer_list<std::pair<fs::path, std::string_view>>{
{ staged / "lib" / "Widget.cppm", "interface unit" },
{ staged / "lib" / "Widget.cpp", "implementation unit" },
{ staged / "lib" / "counter.c", "C source" },
{ staged / "main.cpp", "consumer" },
}) {
Touch(source);
if (BuildOk(app, label)) {
CheckRun(binary, label);
}
}
// The other direction, last because it leaves the fixture broken: a real
// error still has to fail, and still has to say what went wrong.
{
std::ofstream broken(staged / "main.cpp", std::ios::binary | std::ios::trunc);
broken << "int main() { return NotDeclaredAnywhere(); }\n";
broken.close();
Touch(staged / "main.cpp");
BuildResult r = BuildOnce(app);
Check(!r.result.empty(), "a source that fails to compile fails the build");
Check(r.result.contains("error:"), std::format("the failure reports the compiler's diagnostic, got '{}'", r.result));
}
if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", Failures);
return 1;
}
return 0;
}