// 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; // Editing a header must rebuild everything that #includes it. The staleness // check used to compare an artifact against its own source and its module // imports only — and the module scanner reads `import` lines, so a header was // invisible to it. A build after a header-only edit reported nothing to do and // left objects compiled against the previous contents: the same silent // mixed-layout binary as issue #27, reached through #include instead. // // Each pass below edits exactly one header and asserts both directions — the // objects that include it are recompiled, the ones that don't are left alone — // then runs the binary, because "was recompiled" is only interesting if the // resulting program agrees with itself. 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, so work on a copy outside the repo. fs::path StageFixture() { fs::path source = fs::current_path() / "tests" / "IncrementalHeaderChange" / "fixture"; fs::path staged = fs::temp_directory_path() / "crafter-build-incremental-header-change"; fs::remove_all(staged); fs::copy(source, staged, fs::copy_options::recursive); return staged; } // Swap -grown.h.in in for .h. The replacement text lives in a // file rather than a string literal so the header the fixture #includes is // the only one under that name, whichever variant is in place. void Grow(const fs::path& header) { fs::path grown = header.parent_path() / std::format("{}-grown.h.in", header.stem().string()); fs::copy_file(grown, header, fs::copy_options::overwrite_existing); // copy_file carries the source's mtime across, which would leave the // rewritten header looking older than the objects built from it. fs::last_write_time(header, fs::file_time_type::clock::now()); } std::unique_ptr MakeLib(const fs::path& staged) { auto lib = std::make_unique(); lib->path = staged / "lib"; lib->name = "widget"; lib->outputName = "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 = "widget-app"; app.outputName = "widget-app"; app.target = HostTarget(); app.type = ConfigurationType::Executable; std::array ifaces = {}; std::array impls = { "main" }; app.GetInterfacesAndImplementations(ifaces, impls); app.dependencies = { lib }; return app; } bool BuildOk(Configuration& app, std::string_view label) { // 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 second build entirely. std::unordered_map> depResults; std::mutex depMutex; BuildResult r = Build(app, depResults, depMutex); if (!r.result.empty()) { std::println(std::cerr, "FAIL: {} build failed: {}", label, r.result); ++Failures; return false; } return true; } // " " — see // the fixture's main.cpp. void CheckRun(const fs::path& binary, std::string_view expected, std::string_view label) { auto r = RunCommandWithTimeout(binary.string(), std::chrono::seconds(30)); Check(r.exitCode == 0 && !r.crashed && !r.timedOut && r.output == expected, std::format("{}: expected '{}', got '{}' (exit={})", label, expected, r.output, r.exitCode)); } } int main() { fs::path staged = StageFixture(); std::unique_ptr lib = MakeLib(staged); Configuration app = MakeApp(staged, lib.get()); fs::path binary = app.BinDir() / "widget-app"; // One artifact per compile path a header can reach: the module interface // (BMI plus the object made from it), the module's implementation unit, a C // source, and the consumer that imports the module. fs::path interfacePcm = lib->PcmDir() / "Widget.pcm"; fs::path interfaceObject = lib->BuildDir() / "Widget.o"; fs::path libraryObject = lib->BuildDir() / "Widget_impl.o"; fs::path counterObject = lib->BuildDir() / "counter_source.o"; fs::path consumerObject = app.BuildDir() / "main_impl.o"; if (!BuildOk(app, "first pass")) { std::println(std::cerr, "{} assertions failed", Failures); return 1; } CheckRun(binary, "8 8 1 5", "first pass"); auto stamps = [&]() { return std::array{ fs::last_write_time(interfacePcm), fs::last_write_time(interfaceObject), fs::last_write_time(libraryObject), fs::last_write_time(counterObject), fs::last_write_time(consumerObject), }; }; // Nothing changed: the depfiles must not read as staleness of their own, // or every build would recompile the world. { std::array before = stamps(); if (BuildOk(app, "idle pass")) { Check(stamps() == before, "an idle rebuild recompiles nothing"); } } // A header only the module's implementation unit includes. Its name carries // a space, so the depfile spells it escaped. { std::array before = stamps(); Grow(staged / "lib" / "widget count.h"); if (BuildOk(app, "implementation header pass")) { std::array after = stamps(); Check(after[2] > before[2], "implementation object is recompiled after a header it includes changes"); Check(after[0] == before[0], "interface BMI is left alone by a header it does not include"); Check(after[3] == before[3], "C object is left alone by a header it does not include"); Check(after[4] == before[4], "consumer object is left alone by a header it does not include"); CheckRun(binary, "8 8 2 5", "implementation header pass"); } } // A header only the C source includes. { std::array before = stamps(); Grow(staged / "lib" / "counter-limit.h"); if (BuildOk(app, "C header pass")) { std::array after = stamps(); Check(after[3] > before[3], "C object is recompiled after a header it includes changes"); Check(after[2] == before[2], "implementation object is left alone by a header it does not include"); CheckRun(binary, "8 8 2 9", "C header pass"); } } // A header the interface unit includes, changing the layout of an exported // class: the BMI and everything compiled against it has to follow. { std::array before = stamps(); Grow(staged / "lib" / "widget-layout.h"); if (BuildOk(app, "interface header pass")) { std::array after = stamps(); Check(after[0] > before[0], "interface BMI is rebuilt after a header it includes changes"); Check(after[1] > before[1], "interface object is rebuilt after a header it includes changes"); Check(after[2] > before[2], "implementation object follows the rebuilt BMI"); Check(after[4] > before[4], "consumer object follows the rebuilt BMI"); CheckRun(binary, "16 16 2 9", "interface header pass"); } } // An object built before depfiles were emitted at all — an upgrade over an // existing build directory — has no record of what it included, so the one // safe reading is "rebuild it once". { std::array before = stamps(); fs::remove(lib->BuildDir() / "Widget_impl.o.d"); if (BuildOk(app, "missing depfile pass")) { std::array after = stamps(); Check(after[2] > before[2], "an object whose dependency record is missing is rebuilt"); Check(after[3] == before[3], "the objects that still have one are left alone"); } } if (Failures > 0) { std::println(std::cerr, "{} assertions failed", Failures); return 1; } return 0; }