Crafter.Build/tests/IncrementalInterfaceChange/main.cpp

199 lines
9.4 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;
// Adding a data member to a class in a module interface must rebuild every
// object compiled against the old layout. The dangerous shape (issue #27) is a
// consumer whose sources were scanned *before* its `dependencies` were assigned:
// its `import <DepModule>;` matched nothing, so its object carried no staleness
// edge to the interface, and a layout change rebuilt the library, relinked the
// consumer, and produced a binary mixing both layouts with no error or warning.
//
// `AddTest` is exactly that shape — it scans tests/<name>/main.cpp and only then
// returns a builder whose .Dependencies() supplies the library — which is why
// the original report saw the corruption in test executables specifically.
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" / "IncrementalInterfaceChange" / "fixture";
fs::path staged = fs::temp_directory_path() / "crafter-build-incremental-interface-change";
fs::remove_all(staged);
fs::copy(source, staged, fs::copy_options::recursive);
return staged;
}
// Swap in the variant carrying the extra member. Comes from a file rather
// than a string literal here: the module scanner reads raw source, so an
// `export module Widget;` spelled inside this test would make the test look
// like an implementation unit of Widget.
void GrowWidget(const fs::path& staged) {
fs::copy_file(staged / "lib" / "Widget-grown.cppm.in", staged / "lib" / "Widget.cppm", fs::copy_options::overwrite_existing);
// copy_file carries the source's mtime across, which would leave the
// rewritten interface looking older than the BMI built from it.
fs::last_write_time(staged / "lib" / "Widget.cppm", 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);
return lib;
}
// Scan first, wire the dependency up afterwards — the ordering that used to
// silently drop the staleness edge.
Configuration MakeConsumerScannedBeforeDependencies(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;
}
}
int main() {
{
// The scan leaves the unmatched import recorded rather than forgotten,
// and ResolvePendingImports places it once the library is reachable.
fs::path staged = StageFixture();
std::unique_ptr<Configuration> lib = MakeLib(staged);
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);
Check(app.implementations.size() == 1, "consumer has one implementation");
if (app.implementations.size() == 1) {
const Implementation& impl = app.implementations[0];
Check(impl.externalModuleDependencies.empty(), "no external dep resolvable before dependencies are assigned");
Check(std::ranges::find(impl.pendingImports, "Widget") != impl.pendingImports.end(), "unresolved 'import Widget;' is recorded as pending");
app.dependencies = { lib.get() };
app.ResolvePendingImports();
Check(impl.externalModuleDependencies.size() == 1, "ResolvePendingImports adds the external module dep");
if (impl.externalModuleDependencies.size() == 1) {
Check(impl.externalModuleDependencies[0].first->name == "Widget", "external dep is the Widget module");
Check(impl.externalModuleDependencies[0].second == lib->PcmDir() / "Widget.pcm", "external dep points at the library's BMI");
}
Check(std::ranges::find(impl.pendingImports, "Widget") == impl.pendingImports.end(), "resolved import is no longer pending");
// Idempotent: a second sweep must not duplicate the edge (Build
// runs one unconditionally, on top of whatever callers already did).
app.ResolvePendingImports();
Check(impl.externalModuleDependencies.size() == 1, "ResolvePendingImports is idempotent");
}
}
{
// AddTest is the reported path: it scans the test source, then hands
// back a builder whose .Dependencies() names the library.
fs::path staged = StageFixture();
fs::create_directories(staged / "tests" / "Consumer");
fs::copy_file(staged / "main.cpp", staged / "tests" / "Consumer" / "main.cpp");
std::unique_ptr<Configuration> lib = MakeLib(staged);
Configuration app;
app.path = staged;
app.name = "host";
app.outputName = "host";
app.target = HostTarget();
app.type = ConfigurationType::Executable;
// AddTest resolves tests/<name>/main against the cwd, which for a real
// run is the directory holding project.cpp. Stand in the staged project
// for the declaration so the fixture's test source is the one scanned.
fs::path restore = fs::current_path();
fs::current_path(staged);
app.AddTest("Consumer").Dependencies({ lib.get() });
fs::current_path(restore);
Check(app.tests.size() == 1, "one test declared");
if (app.tests.size() == 1 && app.tests[0].config.implementations.size() == 1) {
const Implementation& impl = app.tests[0].config.implementations[0];
Check(impl.externalModuleDependencies.size() == 1, "AddTest(...).Dependencies() resolves the test's import of the library module");
Check(impl.pendingImports.empty() || std::ranges::find(impl.pendingImports, "Widget") == impl.pendingImports.end(), "test's 'import Widget;' is no longer pending");
}
}
{
// End to end, and deliberately without calling ResolvePendingImports:
// the guarantee under test is that Build() closes the window on its own,
// for consumers that never knew they had to ask.
fs::path staged = StageFixture();
std::unique_ptr<Configuration> lib = MakeLib(staged);
Configuration app = MakeConsumerScannedBeforeDependencies(staged, lib.get());
fs::path binary = app.BinDir() / "widget-app";
fs::path consumerObject = app.BuildDir() / "main_impl.o";
if (BuildOk(app, "first pass")) {
auto first = RunCommandWithTimeout(binary.string(), std::chrono::seconds(30));
Check(first.exitCode == 0 && !first.crashed && !first.timedOut, std::format("first pass agrees on the layout (exit={} output='{}')", first.exitCode, first.output));
fs::file_time_type objectBefore = fs::last_write_time(consumerObject);
// Same edit as the original report: one more member on a class in a
// module interface. Every signature and mangled name is unchanged,
// so a missed rebuild produces no diagnostic of any kind.
GrowWidget(staged);
if (BuildOk(app, "second pass")) {
Check(fs::last_write_time(consumerObject) > objectBefore, "consumer object is recompiled after the interface gains a member");
auto second = RunCommandWithTimeout(binary.string(), std::chrono::seconds(30));
Check(second.exitCode == 0 && !second.crashed && !second.timedOut, std::format("second pass agrees on the layout (exit={} output='{}')", second.exitCode, second.output));
}
}
}
if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", Failures);
return 1;
}
return 0;
}