test: header-change incrementality across every compile path
Some checks failed
CI / build-test-release (pull_request) Failing after 12m4s

One header per pass, asserting both directions — the objects that include
it recompile, the ones that don't are untouched — then running the binary,
since "was recompiled" only matters if the program agrees with itself.

Covers the module interface (a macro in its global module fragment decides
an exported class's layout), the implementation unit, a C source, an idle
rebuild that must recompile nothing, and an object whose depfile is gone.
The implementation unit's header has a space in its name so the depfile
spells it escaped.
This commit is contained in:
catbot 2026-07-31 11:11:49 +00:00
commit 7975cb1df8
12 changed files with 317 additions and 0 deletions

View file

@ -0,0 +1,203 @@
// 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;
}