// 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 MakeLib(const fs::path& staged) { auto lib = std::make_unique(); lib->path = staged / "lib"; lib->name = "noisy-widget"; lib->outputName = "noisy-widget"; lib->target = HostTarget(); lib->type = ConfigurationType::LibraryStatic; std::array ifaces = { "Widget" }; std::array 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 ifaces = {}; std::array 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> 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 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>{ { 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; }