diff --git a/project.cpp b/project.cpp index f3e5af1..19e4f59 100644 --- a/project.cpp +++ b/project.cpp @@ -110,6 +110,7 @@ extern "C" Configuration CrafterBuildProject(std::span 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() }); diff --git a/tests/CompilerWarning/fixture/lib/Widget.cpp b/tests/CompilerWarning/fixture/lib/Widget.cpp new file mode 100644 index 0000000..692432b --- /dev/null +++ b/tests/CompilerWarning/fixture/lib/Widget.cpp @@ -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; } diff --git a/tests/CompilerWarning/fixture/lib/Widget.cppm b/tests/CompilerWarning/fixture/lib/Widget.cppm new file mode 100644 index 0000000..4a1bcac --- /dev/null +++ b/tests/CompilerWarning/fixture/lib/Widget.cppm @@ -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(); diff --git a/tests/CompilerWarning/fixture/lib/counter.c b/tests/CompilerWarning/fixture/lib/counter.c new file mode 100644 index 0000000..2986d15 --- /dev/null +++ b/tests/CompilerWarning/fixture/lib/counter.c @@ -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; +} diff --git a/tests/CompilerWarning/fixture/main.cpp b/tests/CompilerWarning/fixture/main.cpp new file mode 100644 index 0000000..ef58cf6 --- /dev/null +++ b/tests/CompilerWarning/fixture/main.cpp @@ -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 " ". 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; +} diff --git a/tests/CompilerWarning/main.cpp b/tests/CompilerWarning/main.cpp new file mode 100644 index 0000000..91bd2aa --- /dev/null +++ b/tests/CompilerWarning/main.cpp @@ -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 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; +}