Crafter.Build/tests/IncrementalHeaderChange/main.cpp

203 lines
9 KiB
C++
Raw Normal View History

// 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 <name>-grown.h.in in for <name>.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<Configuration> MakeLib(const fs::path& staged) {
auto lib = std::make_unique<Configuration>();
lib->path = staged / "lib";
lib->name = "widget";
lib->outputName = "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 = "widget-app";
app.outputName = "widget-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 };
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<fs::path, std::shared_future<BuildResult>> 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;
}
// "<sizeof in the consumer> <sizeof in the library> <count> <limit>" — 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<Configuration> 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::file_time_type, 5>{
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<fs::file_time_type, 5> 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<fs::file_time_type, 5> before = stamps();
Grow(staged / "lib" / "widget count.h");
if (BuildOk(app, "implementation header pass")) {
std::array<fs::file_time_type, 5> 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<fs::file_time_type, 5> before = stamps();
Grow(staged / "lib" / "counter-limit.h");
if (BuildOk(app, "C header pass")) {
std::array<fs::file_time_type, 5> 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<fs::file_time_type, 5> before = stamps();
Grow(staged / "lib" / "widget-layout.h");
if (BuildOk(app, "interface header pass")) {
std::array<fs::file_time_type, 5> 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<fs::file_time_type, 5> before = stamps();
fs::remove(lib->BuildDir() / "Widget_impl.o.d");
if (BuildOk(app, "missing depfile pass")) {
std::array<fs::file_time_type, 5> 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;
}